Faking Session Cookies in Integration Tests

Cookie-based sessions are harder to fake than bearer tokens because the cookie is rarely the session itself — it is a signed pointer to a row in a store, or an encrypted blob only the server can read. Tests that need an authenticated request therefore tend to do one of two things: log in through the real form on every test, which is slow, or set req.user directly through a test-only middleware, which is fast and bypasses the session code entirely. This guide takes the path between them: create the session through the application’s own store, produce the cookie with the application’s own signing, and send it with the request so every layer — cookie parsing, signature check, store lookup, expiry — runs as it does in production. It covers express-session, iron-session style encrypted cookies and database-backed sessions, and sits under authentication and session mocking.

Root Cause Analysis

The test-only middleware shortcut is appealing because it is one line: if a header is present, trust it as the user. It is also the single most dangerous pattern in this area, because a middleware registered conditionally on an environment variable is one misconfigured deployment away from accepting any identity anyone cares to send. Even when it never reaches production, it means the session code — cookie parsing, signature verification, rolling expiry, store lookup — has no test coverage at all.

Logging in through the form avoids that and pays for it in time. A form login involves password hashing, which is deliberately slow; a suite that logs in per test can spend more time hashing passwords than testing anything.

The underlying observation is that a session is just data the application wrote and later reads back. Tests can write that data through the same code path the login handler uses, then hand the resulting cookie to the request. Nothing is bypassed and nothing is slow.

Three ways to authenticate an integration request Logging in through the form is real but slow, a trust-this-header middleware is fast but bypasses the session code, and creating the session through the store and signing the cookie is both fast and real. form login every layer real password hashing each time ~200 ms per test correct, too slow trust a header session code skipped conditional middleware one flag from a vulnerability fast, dangerous seed and sign session written to the store cookie signed with the secret ~2 ms per test fast and real
Seeding through the store gets the speed of the shortcut with the coverage of the login.

Reproducible Setup

Start from an Express application using a store-backed session, which is the most common shape and the one with the most moving parts.

// src/session.ts — production configuration, exported so tests can reuse it
import session from 'express-session';
import RedisStore from 'connect-redis';
import { redis } from './redis';

export const SESSION_COOKIE = 'sid';
export const store = new RedisStore({ client: redis, prefix: 'sess:' });

export const sessionMiddleware = session({
  name: SESSION_COOKIE,
  store,
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 86_400_000 },
});
// vitest.config.ts
export default defineConfig({
  test: {
    env: { SESSION_SECRET: 'test-secret-not-used-anywhere-else', REDIS_URL: 'redis://localhost:6379/15' },
    setupFiles: ['./test/setup.ts'],
  },
});

Implementation

Step 1 — Write the session through the store the application uses. The shape is whatever the login handler writes; importing the store means the test cannot drift from it.

// test/auth/session-cookie.ts
import { randomUUID } from 'node:crypto';
import signature from 'cookie-signature';
import { store, SESSION_COOKIE } from '../../src/session';

type SessionData = { userId: string; roles: string[] };

export async function aSessionCookie(data: Partial<SessionData> = {}) {
  const sid = randomUUID();
  const payload = {
    cookie: { originalMaxAge: 86_400_000, expires: new Date(Date.now() + 86_400_000), httpOnly: true, path: '/' },
    userId: data.userId ?? 'user_1',
    roles: data.roles ?? ['member'],
  };
  await new Promise<void>((resolve, reject) => store.set(sid, payload as any, (err) => (err ? reject(err) : resolve())));

  const signed = 's:' + signature.sign(sid, process.env.SESSION_SECRET!);
  return `${SESSION_COOKIE}=${encodeURIComponent(signed)}`;
}

Step 2 — Send the cookie with the request. The middleware parses it, verifies the signature, looks the session up and populates req.session — every step real.

// src/routes/account.test.ts
import request from 'supertest';
import { test, expect } from 'vitest';
import { app } from '../app';
import { aSessionCookie } from '../../test/auth/session-cookie';

test('returns the signed-in user’s profile', async () => {
  const cookie = await aSessionCookie({ userId: 'user_42' });
  const res = await request(app).get('/account').set('cookie', cookie).expect(200);
  expect(res.body.id).toBe('user_42');
});

test('treats a request with no cookie as signed out', async () => {
  await request(app).get('/account').expect(401);
});

Step 3 — For encrypted-cookie sessions, seal with the real library. Stateless sessions put the data in the cookie itself, encrypted; the test seals a payload with the same password and library.

// test/auth/sealed-cookie.ts
import { sealData } from 'iron-session';

export async function aSealedCookie(data = { userId: 'user_1', roles: ['member'] }) {
  const sealed = await sealData(data, { password: process.env.SESSION_PASSWORD!, ttl: 3600 });
  return `app_session=${sealed}`;
}

Step 4 — For database sessions, insert a row and use its identifier. The cookie carries only the session token; the row is what gives it meaning.

// test/auth/db-session.ts
import { db } from '../../src/db';
import { randomBytes } from 'node:crypto';

export async function aDbSessionCookie(userId = 'user_1') {
  const token = randomBytes(32).toString('hex');
  await db.session.create({ data: { sessionToken: token, userId, expires: new Date(Date.now() + 86_400_000) } });
  return `session_token=${token}`;
}
Three session storage styles and what the test writes for each A store-backed session needs a record in the store and a signed session identifier, an encrypted cookie needs a payload sealed with the real password, and a database session needs a row plus its token in the cookie. store-backed write: store.set(sid, data) cookie: signed sid express-session, connect-redis encrypted cookie write: nothing server-side cookie: sealed payload iron-session, encrypted JWT database session write: a session row cookie: random token Auth.js database strategy, Lucia
Whatever the storage style, the test writes what the login handler would have written, with the same code.

Step 5 — Clean up sessions the same way you clean up any test data. Sessions written to a shared store accumulate, and a stale one can occasionally match a later test’s identifier.

// test/setup.ts
import { afterAll } from 'vitest';
import { redis } from '../src/redis';

afterAll(async () => {
  const keys = await redis.keys('sess:*');
  if (keys.length) await redis.del(keys);
  await redis.quit();
});

Step 6 — Test the rejection paths too. A tampered signature, an expired session and a session for a deleted user each exercise different code, and each is one line to produce.

test('rejects a cookie with a tampered signature', async () => {
  const cookie = (await aSessionCookie()).replace(/.$/, (c) => (c === 'a' ? 'b' : 'a'));
  await request(app).get('/account').set('cookie', cookie).expect(401);
});

It helps to keep the three helpers — store-backed, sealed and database — behind one function the rest of the suite calls, even if only one style is used today. Applications change session strategy more often than teams expect, usually when moving to a stateless deployment or adopting a new auth library, and a single signedInAs(user) helper turns that migration from a rewrite of every authenticated test into a change to one file.

// test/auth/index.ts — the only thing tests import
export const signedInAs = (user: { id: string; roles?: string[] }) =>
  aSessionCookie({ userId: user.id, roles: user.roles });

Verification

Confirm the session layer is load-bearing by running with a different secret. A cookie signed with the test secret must be rejected when the application is configured with another one; if it is accepted, signature checking is not happening.

SESSION_SECRET=a-different-secret npx vitest run src/routes/account.test.ts
# FAIL returns the signed-in user’s profile — expected 200, got 401   ← as it should

Then confirm no bypass exists anywhere in the application.

grep -rnE "x-test-user|req\.user\s*=.*header|NODE_ENV === 'test'" src/
# (no output)
Rejection cases that prove the session layer runs A tampered signature, an expired session, a session whose user was deleted, and a cookie signed with the wrong secret must each be treated as signed out. tampered signature one byte changed expired past maxAge user deleted session outlives account wrong secret signed elsewhere
If all four are rejected, the fast seeded path is exercising the same checks a real login relies on.

Troubleshooting

Symptom: the cookie is ignored and every request is signed out. Diagnosis: the signed value is not URL-encoded, or the s: prefix express-session expects is missing. Fix: build the cookie exactly as in Step 1 and compare it byte for byte against one captured from a real login in a development session.

Symptom: sessions work in one test file and not the next. Diagnosis: the store connection is closed by an earlier file’s teardown while the application module still holds the client. Fix: close shared connections once per worker rather than per file, or give each file its own Redis database number.

Symptom: the secure cookie flag stops the cookie being sent. Diagnosis: the test environment runs with production cookie settings over plain HTTP. Fix: derive secure from the environment as the setup does, and assert in a separate test that production configuration sets it — the flag is important, just not over a loopback connection.

Symptom: tests leak sessions that later collide. Diagnosis: identifiers are generated predictably, or cleanup is skipped when a test fails. Fix: use random identifiers and clean up in a hook rather than at the end of the test body, following the teardown discipline in resetting state between tests without slowing CI.

FAQ

Is importing the session store into tests a coupling problem?

It is the right coupling. The test’s job is to produce exactly what the login handler produces, and importing the same store and configuration guarantees it. The coupling you want to avoid is to hand-written session shapes, which drift from production silently.

Should the test secret match production?

Never. Use a dedicated value that exists only in test configuration, and verify in CI that the production secret is not present in the repository. The point of the secret-mismatch check in the verification step is that it makes a leaked or shared secret visible.

What about CSRF protection on cookie-authenticated routes?

Treat it as part of the pipeline under test. Fetch a token the way the browser would — from a form or an endpoint — and send it with the mutating request. A test that disables CSRF protection for convenience has the same flaw as a test that disables authentication.

How does this relate to Playwright storage state?

It is the same idea one tier down. Playwright’s storage state is a saved cookie jar; here you create the cookie directly. Both avoid repeating the login flow, and both keep the production session code in the loop, as reusing authenticated state across Playwright tests describes for the browser.