Mocking JWT Auth in Vitest API Tests

The tempting way to test an API that requires a bearer token is to stub the verification function — vi.mock('./verifyToken', () => ({ verify: () => ({ sub: 'u1' }) })) — and move on. It works immediately and quietly removes the one piece of code you most need confidence in. This guide takes the other route: tests mint genuine JWTs signed with a key pair generated for the run, MSW serves the matching public key from the issuer’s JWKS URL, and the middleware runs exactly as it does in production. It is for backend and full-stack engineers testing Express, Fastify, Hono or Next.js route handlers with Vitest 2.x and MSW 2.x, and it covers the key setup, a token builder, the claims worth varying, and the cache and clock pitfalls that make first attempts fail. It sits under authentication and session mocking.

Root Cause Analysis

Stubbing the verifier fails in a way that is invisible from inside the test suite. Every test passes, coverage of the route is complete, and the verification code — the signature check, the issuer match, the audience match, the expiry comparison, the key rotation logic — has not executed once. A bug introduced there, or a misconfiguration of its inputs, reaches production with a green build.

The failure vectors are specific and common. An audience string changes during a refactor and nothing notices. A library upgrade changes the default clock tolerance. A new identity provider issues tokens with the key identifier in a different header field. Each of these breaks every real request while leaving a stubbed suite untouched.

The fix does not need to be expensive. Asymmetric signing in Node takes a fraction of a millisecond, and a JWKS response served by MSW is an in-memory lookup. The whole apparatus adds a few milliseconds per test file while restoring coverage of the code that decides whether a request is allowed at all.

What a stubbed verifier skips With the verifier stubbed, the request goes straight from the header to the handler; with a signed test token, the key fetch, signature check, issuer, audience and expiry checks all execute before the handler runs. Stubbed verifier header handler returns a fixed user — verification never runs Signed test token header JWKS signature iss / aud exp handler the amber boxes are the code a stub removes from every test
The stub removes precisely the checks that decide whether a request is allowed.

Reproducible Setup

Install the signing library and the network interceptor, and make the application read its verification settings from the environment.

npm install -D vitest msw jose supertest @types/supertest
// src/auth/verify.ts — production code, unchanged by tests
import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));

export async function verifyBearer(header: string | undefined) {
  if (!header?.startsWith('Bearer ')) throw new AuthError('missing_token');
  const { payload } = await jwtVerify(header.slice(7), jwks, {
    issuer: process.env.AUTH_ISSUER,
    audience: process.env.AUTH_AUDIENCE,
  });
  return { id: payload.sub!, roles: (payload.roles as string[]) ?? [] };
}
// vitest.config.ts
export default defineConfig({
  test: {
    environment: 'node',
    setupFiles: ['./test/auth/setup.ts'],
    env: {
      AUTH_ISSUER: 'https://auth.test.example',
      AUTH_AUDIENCE: 'api://acme',
      AUTH_JWKS_URL: 'https://auth.test.example/.well-known/jwks.json',
    },
  },
});

Implementation

Step 1 — Generate one key pair per worker. Generating per test is wasteful, and generating per file clashes with the verifier’s key cache; per worker is the grain that avoids both problems.

// test/auth/keys.ts
import { generateKeyPair, exportJWK, type KeyLike } from 'jose';

let pair: { privateKey: KeyLike; publicJwk: Record<string, unknown> } | undefined;

export async function testKeyPair() {
  if (!pair) {
    const { privateKey, publicKey } = await generateKeyPair('RS256');
    pair = { privateKey, publicJwk: { ...(await exportJWK(publicKey)), kid: 'test-1', alg: 'RS256', use: 'sig' } };
  }
  return pair;
}

Step 2 — Serve the public key from the issuer URL with MSW. The verifier makes its real HTTP request, and MSW answers it — no change to production code, no special test path.

// test/auth/setup.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { testKeyPair } from './keys';

export const server = setupServer(
  http.get('https://auth.test.example/.well-known/jwks.json', async () => {
    const { publicJwk } = await testKeyPair();
    return HttpResponse.json({ keys: [publicJwk] });
  }),
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Step 3 — Build tokens with explicit, overridable claims. Every test states the claims it is about; everything else takes a realistic default.

// test/auth/token.ts
import { SignJWT } from 'jose';
import { testKeyPair } from './keys';

type TokenOptions = {
  sub?: string;
  roles?: string[];
  audience?: string;
  issuer?: string;
  expiresIn?: string | number;
  extra?: Record<string, unknown>;
};

export async function signTestToken(o: TokenOptions = {}) {
  const { privateKey } = await testKeyPair();
  return new SignJWT({ roles: o.roles ?? ['member'], ...o.extra })
    .setProtectedHeader({ alg: 'RS256', kid: 'test-1' })
    .setSubject(o.sub ?? 'user_1')
    .setIssuer(o.issuer ?? 'https://auth.test.example')
    .setAudience(o.audience ?? 'api://acme')
    .setIssuedAt()
    .setExpirationTime(o.expiresIn ?? '10m')
    .sign(privateKey);
}

Step 4 — Test the route through the real middleware. The handler, the verification and the permission check all run; only the key’s origin is under the test’s control.

// src/routes/orders.test.ts
import request from 'supertest';
import { test, expect } from 'vitest';
import { app } from '../app';
import { signTestToken } from '../../test/auth/token';

test('returns the caller’s own orders', async () => {
  const token = await signTestToken({ sub: 'user_42' });
  const res = await request(app).get('/me/orders').set('authorization', `Bearer ${token}`).expect(200);
  expect(res.body.every((o: { customerId: string }) => o.customerId === 'user_42')).toBe(true);
});

test('forbids a member from listing all orders', async () => {
  const token = await signTestToken({ roles: ['member'] });
  await request(app).get('/admin/orders').set('authorization', `Bearer ${token}`).expect(403);
});
Which claim to vary for which behaviour Vary the subject to test ownership rules, the roles claim to test permissions, the audience and issuer to test rejection of foreign tokens, and the expiry to test lifetime handling. Claim Behaviour it exercises sub ownership — can I see another user's order? roles permission — may a member refund? aud, iss rejection of tokens meant for another service exp lifetime — expired and not-yet-valid tokens
Four claims cover almost every authentication behaviour a route has; the builder makes each one a single override.

Step 5 — Cover the rejections explicitly. These are the tests that prove verification runs; without them, a passing suite is compatible with no verification at all.

test.each([
  ['expired', { expiresIn: '-1s' }],
  ['wrong audience', { audience: 'api://someone-else' }],
  ['wrong issuer', { issuer: 'https://evil.example' }],
])('rejects a token that is %s', async (_label, opts) => {
  const token = await signTestToken(opts);
  await request(app).get('/me/orders').set('authorization', `Bearer ${token}`).expect(401);
});

Step 6 — Include a foreign-key case. A token signed by a key the JWKS does not publish must fail on signature, which is the one rejection the other cases do not reach.

import { generateKeyPair, SignJWT } from 'jose';

test('rejects a token signed with an unknown key', async () => {
  const { privateKey } = await generateKeyPair('RS256');
  const forged = await new SignJWT({ roles: ['admin'] })
    .setProtectedHeader({ alg: 'RS256', kid: 'test-1' })
    .setSubject('attacker').setIssuer('https://auth.test.example').setAudience('api://acme')
    .setExpirationTime('10m').sign(privateKey);
  await request(app).get('/admin/orders').set('authorization', `Bearer ${forged}`).expect(401);
});

Verification

Run the route tests and read the counts: the accepted and rejected cases should both be present, and a suite with only accepted cases is the warning sign.

npx vitest run src/routes --reporter=verbose
# ✓ returns the caller’s own orders
# ✓ forbids a member from listing all orders
# ✓ rejects a token that is expired
# ✓ rejects a token that is wrong audience
# ✓ rejects a token that is wrong issuer
# ✓ rejects a token signed with an unknown key

Then confirm verification is genuinely load-bearing by breaking it on purpose. Comment out the audience check in the verifier and run the suite; exactly the wrong-audience test should fail. If nothing fails, the tests are not reaching the code you think they are.

sed -i 's/audience: process.env.AUTH_AUDIENCE,/\/\/ audience removed/' src/auth/verify.ts
npx vitest run src/routes
# FAIL rejects a token that is wrong audience — expected 401, got 200
git checkout src/auth/verify.ts
Why key pairs belong at worker scope Generating a new key per test file while the verifier caches the first key set makes later files fail with signature errors; generating once per worker keeps the cached key and the signing key in agreement. key per file file 1 signs with key A file 2 signs with key B verifier still caches A — fails key per worker every file signs with key A verifier caches key A signatures always match
The cache is a production feature working correctly; the test setup has to be designed around it.

Troubleshooting

Symptom: JWSSignatureVerificationFailed on every request after the first file. Diagnosis: the key pair is regenerated per file while the verifier’s remote key set is cached from the first fetch. Fix: generate once per worker as in Step 1, or construct the remote key set lazily so it is rebuilt in each file’s module scope.

Symptom: onUnhandledRequest: 'error' fires for the JWKS URL. Diagnosis: the verifier module was imported before the MSW server started listening, and it fetched eagerly at import. Fix: fetch lazily in the verifier — createRemoteJWKSet already does — or make sure the setup file runs before any application import, which Vitest’s setupFiles guarantees.

Symptom: freshly minted tokens are rejected as not yet valid. Diagnosis: fake timers are installed and the issued-at claim reflects fake time, while the verifier reads a different clock. Fix: mint tokens before installing fake timers, or pass an explicit currentDate to the verifier in tests that control time; this interaction is covered in controlling Date.now and setTimeout in Jest.

Symptom: tests pass locally and fail in CI with a network error. Diagnosis: the application reads AUTH_JWKS_URL from a .env file that exists locally but not in CI, so it falls back to the real provider URL, which MSW does not intercept. Fix: set the variables in the Vitest configuration’s env block, as in the setup, so they never depend on a developer’s local file.

FAQ

Is this slower than stubbing the verifier?

Marginally — a few milliseconds per file for key generation and a sub-millisecond signature per token. Against a typical integration test that makes a database query, the difference is noise. What you buy is coverage of the verification code, which is the code whose failure has the largest blast radius in the application.

Should I use HS256 with a shared secret instead, for simplicity?

Only if production does. The point is to exercise the production verification path, and a verifier configured for asymmetric keys behaves differently from one configured for a shared secret — including which misconfigurations it tolerates. Match the algorithm, and the test suite will catch the key-handling mistakes that matter.

How do I test key rotation?

Serve two keys from the JWKS handler with different key identifiers, sign one token with each, and assert both are accepted. Then remove the old key from the handler and assert tokens signed with it are rejected. That is the whole behaviour, and it is worth a test because rotation bugs tend to surface at the worst possible moment.

Does this work with Next.js route handlers?

Yes — the verifier is the same, and route handlers can be called directly with a Request carrying the authorization header. The Next.js-specific parts, such as session callbacks and server components, are covered in testing NextAuth-protected routes.