Authentication & Session Mocking
Almost every interesting behaviour in an application sits behind a login, which makes authentication the dependency that every test tier trips over first. Unit tests need a user object; integration tests need a request that passes the auth middleware; component tests need a session in context; end-to-end tests need a browser that is already signed in. Using the real identity provider for any of these makes tests slow, couples them to a third party’s availability, and — worst of all — pushes teams into weakening production auth code with test-only bypasses. This topic belongs to advanced mocking and service isolation patterns and covers how to isolate authentication at each tier while keeping the production code path intact: signed test tokens instead of disabled verification, seeded sessions instead of scripted logins, and stubbed provider redirects instead of real OAuth round trips.
Architectural Scope & Boundaries
This topic is about testing code that consumes authentication — route handlers that read the current user, components that render differently for administrators, jobs that act on someone’s behalf. It is not about testing an identity provider itself, and it is not about penetration testing your auth implementation; both are important and both need different tools.
The central boundary is between replacing the source of identity and replacing the verification of it. Replacing the source — issuing a token from a test key instead of from the real provider — is safe, because the application still checks the signature, the expiry and the audience exactly as it would in production. Replacing the verification — an environment flag that skips the check, a middleware that trusts any header — is dangerous, because the code path your tests exercise is no longer the one your users hit, and a misconfigured flag in production turns a test convenience into a vulnerability.
Everything in this topic follows from choosing the first over the second. Tokens are real tokens signed with a key the test controls. Sessions are real sessions created through the application’s own session store. OAuth redirects are intercepted at the network boundary, not disabled in the application. The auth code under test is the auth code that ships.
There is also a tier boundary. Role and permission logic — who may refund an order, who may see another user’s data — is business logic and belongs at the unit tier as a pure function of the user and the resource. The middleware that establishes who the user is belongs at the integration tier. The login journey itself belongs to exactly one end-to-end test. Confusing these produces suites where every permission case is verified through a browser, which is the slowest possible way to test a boolean.
Finally, the scope excludes production-like identity federation — SAML assertions from a real enterprise directory, multi-factor challenges against a real device. Those deserve a small number of deliberate tests in a dedicated environment rather than being simulated in every run.
It is worth spelling out why the bypass pattern is so persistent despite being widely known to be risky. It is the fastest thing to write on the day the first authenticated test is needed, it makes every subsequent test easier, and its danger is entirely hypothetical until the day it is not. The signed-token approach costs perhaps an hour more on that first day, and after that it is no harder to use than the bypass — which is why it is worth insisting on from the start rather than retrofitting later, when the bypass has spread through a hundred test files.
Prerequisites
Step-by-Step Implementation
Step 1 — Generate a test key pair and point verification at it. The application verifies tokens against a public key it reads from configuration. In tests, that key belongs to a pair the test suite generated, so it can sign tokens the application will genuinely accept.
// test/auth/keys.ts
import { generateKeyPair, exportJWK } from 'jose';
export const testKeys = await generateKeyPair('RS256');
export const testJwks = { keys: [{ ...(await exportJWK(testKeys.publicKey)), kid: 'test-key', alg: 'RS256' }] };
// vitest.setup.ts — point the app at the test issuer before it loads
process.env.AUTH_ISSUER = 'https://auth.test.example';
process.env.AUTH_AUDIENCE = 'api://acme';
process.env.AUTH_JWKS_URL = 'https://auth.test.example/.well-known/jwks.json';
Generating the key pair at runtime rather than committing one is deliberate. A committed private key, even one labelled as a test key, is a credential in the repository, and someone will eventually point a staging environment at it for convenience. A key that exists only in memory for the duration of a test run cannot be misused that way.
Step 2 — Serve the test key set from the network boundary. The application fetches its verification keys exactly as it would in production; MSW answers that request with the test public key.
// test/auth/handlers.ts
import { http, HttpResponse } from 'msw';
import { testJwks } from './keys';
export const authHandlers = [
http.get('https://auth.test.example/.well-known/jwks.json', () => HttpResponse.json(testJwks)),
];
Step 3 — Sign tokens with a builder. Tests state the claims they care about and inherit sensible defaults for everything else, the same builder pattern used for any other test data.
// test/auth/token.ts
import { SignJWT } from 'jose';
import { testKeys } from './keys';
type Claims = { sub?: string; roles?: string[]; email?: string; expiresIn?: string };
export async function aToken({ sub = 'user_1', roles = ['member'], email = 'ada@example.test', expiresIn = '10m' }: Claims = {}) {
return new SignJWT({ roles, email })
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
.setSubject(sub)
.setIssuer('https://auth.test.example')
.setAudience('api://acme')
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(testKeys.privateKey);
}
The builder’s defaults matter more than they appear. A default of roles: ['member'] — the least privileged real role — means any test that forgets to state a role runs as an ordinary user, so a permission bug fails loudly rather than being masked by a test that accidentally ran as an administrator. Defaulting to the most privileged role is a common and quiet mistake that makes the whole suite less sensitive to exactly the defects that matter most.
Step 4 — Test permission rules as pure functions. The decision of who may do what does not need a token, a request or a server; it needs a user and a resource.
// src/domain/permissions.test.ts
import { test, expect } from 'vitest';
import { canRefund } from './permissions';
test.each([
['a member', { roles: ['member'] }, false],
['support staff', { roles: ['support'] }, true],
['an admin', { roles: ['admin'] }, true],
])('%s may refund: %s', (_label, user, expected) => {
expect(canRefund(user, { status: 'placed' })).toBe(expected);
});
Keeping this as a pure function is also what makes the permission matrix exhaustive in practice. Every combination of role and resource state can be a row in a table, running in microseconds, and a new role added next quarter is one more column rather than a new set of browser tests. The integration tier then only has to prove that the middleware passes the right user into this function — one test, not a matrix.
Step 5 — Send authenticated requests at the integration tier. The middleware, the route and the permission rule run together, with a token the test minted.
// src/routes/refunds.integration.test.ts
import request from 'supertest';
import { app } from '../app';
import { aToken } from '../../test/auth/token';
test('a member is forbidden from issuing refunds', async () => {
const token = await aToken({ roles: ['member'] });
await request(app).post('/orders/o1/refunds').set('authorization', `Bearer ${token}`)
.send({ amountPence: 500 }).expect(403);
});
test('an expired token is rejected before the handler runs', async () => {
const token = await aToken({ expiresIn: '-1m' });
await request(app).post('/orders/o1/refunds').set('authorization', `Bearer ${token}`).expect(401);
});
Step 6 — Supply sessions to components through context, not through the network. A component that renders an account menu needs a session object, and the render helper can provide one directly.
// test/render-with-session.tsx
import { render } from '@testing-library/react';
import { SessionContext } from '../src/auth/session-context';
export function renderSignedIn(ui: React.ReactElement, session = { user: { id: 'u1', name: 'Ada', roles: ['member'] } }) {
return render(<SessionContext.Provider value={session}>{ui}</SessionContext.Provider>);
}
Component tests that need to exercise the unauthenticated state simply render without the provider, or with a null session. That gives two cheap tests for every component that behaves differently when signed out, without any network, cookie or token machinery at all — which is the level of cost these checks deserve.
Configuration Reference Table
| Setting | Type | Where | Effect |
|---|---|---|---|
AUTH_ISSUER |
string | env | The issuer the middleware requires; tests use a reserved test domain. |
AUTH_AUDIENCE |
string | env | Must match between test tokens and verification, or every request is rejected. |
AUTH_JWKS_URL |
URL | env | Where public keys are fetched; intercepted by MSW in tests. |
| JWKS cache TTL | duration | library | A long cache can hold a stale key across test files; reset it in setup. |
alg |
enum | token header | Use the production algorithm; testing HS256 when production uses RS256 hides key bugs. |
kid |
string | token header | Selects the verification key; a mismatch produces a confusing signature error. |
| clock tolerance | seconds | verifier | Allows small skew; tests that assert expiry should set it explicitly. |
| session store | adapter | app config | Where component and e2e sessions are seeded; must be writable by tests. |
Two of these settings cause most of the confusing failures. An audience mismatch produces a rejection that looks exactly like a bad signature in many libraries’ error messages, so it is worth logging the specific verification failure in test runs. And the algorithm must match production: a suite that signs with a shared secret while production verifies with a public key has not tested key handling at all, and the first time the difference matters is in a deployment.
Verification & Assertions
The most important verification is negative: prove that the application rejects what it should. A suite that only tests valid tokens cannot tell you whether verification is running at all.
test.each([
['a token signed by a different key', await tokenFromForeignKey()],
['a token for another audience', await aToken().then(retarget('api://other'))],
['a token with no signature', unsignedToken()],
['no token', undefined],
])('rejects %s', async (_label, token) => {
const req = request(app).get('/me');
if (token) req.set('authorization', `Bearer ${token}`);
await req.expect(401);
});
Then verify that no bypass exists, mechanically. A search for environment-conditional auth is worth running in CI, because a skip flag added for convenience is exactly the kind of change that looks harmless in review.
grep -rnE "(SKIP|DISABLE|BYPASS)_AUTH|if \(process\.env\.NODE_ENV === 'test'\).*auth" src/
# (no output — verification is never conditional on the environment)
A further check worth adding is that the token builder’s output is realistic. Decode a production token once — with its signature removed — and compare its claim set against a test token. Missing claims that production always carries, such as a tenant identifier or a session identifier, are the usual cause of code that works in tests and fails for real users, because the tests never exercised the path that reads them.
Finally, verify the test key cannot be used outside tests. The private key is generated at runtime and never written to disk or configuration, so there is nothing to leak; confirming that the production issuer is not the test issuer closes the loop.
Edge Cases & Failure Modes
Cached keys across test files. Many JWT libraries cache the fetched key set for minutes. If the test suite regenerates its key pair per file, a cached public key from the previous file makes every token fail verification with a signature error that looks like a bug in the token builder. Generate the pair once per worker, or clear the library’s cache in setup.
Clock-dependent expiry under fake timers. Tests that install fake timers and then mint a token will produce a token whose issued-at time is the fake time, which the verifier may reject as not yet valid. Mint tokens before installing fake timers, or set an explicit clock on the verifier that follows the fake one.
Roles tested only through the happy path. A suite with a hundred tests as an administrator and none as a regular member has verified that administrators can do things, not that members cannot. Permission bugs are almost always grants that should not exist, so the negative cases carry most of the value.
A test-only route that ships. A convenience endpoint for creating sessions in tests — /__test/login — is useful and dangerous. Register it only when a build-time flag is set, verify in CI that the production bundle does not contain it, and never gate it on a runtime environment variable.
Tokens that outlive a long test. A ten-minute default lifetime is plenty for a unit or integration test, but a slow end-to-end journey against a remote environment can exceed it, producing a failure at the last step that has nothing to do with the step. Mint longer-lived tokens for that tier specifically rather than raising the default for everyone.
Performance & CI Impact
Signed test tokens are cheap: RS256 signing takes well under a millisecond, and generating the key pair once per worker adds a few milliseconds to startup. The dominant cost in most suites is not token handling but the login flows that tokens replace — a browser login costs seconds, and a suite that performs one per test pays that cost hundreds of times.
The largest single saving is at the end-to-end tier, where seeding a session into storage state removes the login form from every journey. The approach is covered in detail in reusing authenticated state across Playwright tests, and it typically removes a third or more of the suite’s wall clock on its own.
Flakiness risk drops too, because the identity provider leaves the critical path. A test that depends on a third-party login page fails when that page is slow, redesigned or rate-limited, none of which has anything to do with your code. Intercepting the provider at the network boundary makes those failures impossible rather than merely rare.
The remaining risk is drift between the test issuer’s configuration and production’s — a new required claim, a changed audience. A single deliberate test against a real staging identity provider, run nightly rather than per pull request, catches that without reintroducing the dependency into every run.
One last practical note: keep all of the auth test utilities — key generation, the token builder, the MSW handlers, the render helper — in one place that every package imports. Auth setup copied into several test directories inevitably diverges, and a divergence in something as security-sensitive as token verification is precisely the kind of inconsistency that lets a real defect pass in one area while being caught in another.
In-Depth Guides
- Mocking JWT auth in Vitest API tests — signed test tokens, key sets served by MSW, and the negative cases.
- Testing NextAuth-protected routes — sessions for App Router handlers and server components without a live provider.
- Stubbing OAuth redirect flows in Playwright — intercept the provider hop and keep the callback real.
- Faking session cookies in integration tests — create sessions through the real store and send the real cookie.
Related
- Back to Advanced Mocking & Service Isolation Patterns
- External Service Simulation — the MSW foundations this topic builds on.
- Reusing authenticated state across Playwright tests — the end-to-end side of session handling.
- Third-Party SDK Isolation — the same boundary discipline applied to other providers.
Mocking JWT Auth in Vitest API Tests
Sign real tokens with a test key, serve its JWKS through MSW, and run your verification middleware unchanged, covering expiry, audience and roles.
Testing NextAuth-Protected Routes
Test Auth.js-protected route handlers, server actions and server components in Vitest by injecting sessions at the auth() seam, signed-out path included.
Faking Session Cookies in Integration Tests
Create sessions through the app's own store and sign cookies with its real secret, so cookie-authenticated routes are tested without a login form.
Stubbing OAuth Redirect Flows in Playwright
Test 'Sign in with…' without a real provider: intercept the authorize hop with page.route, hit your real callback, and stub the token exchange.