Verifying JWTs at the Edge with jose

This guide is part of Authentication and Session Handling at the Edge. It solves one concrete problem: taking a bearer token off an incoming request and deciding, inside the isolate and without a network round trip, whether it is genuine.

The problem

You add token verification to your middleware and one of three things happens. The build fails with Module not found: Can't resolve 'crypto'. Or it builds, deploys, and every request costs 40 ms because the middleware fetches your issuer’s JWKS document on each invocation. Or it works beautifully for a month and then every user is signed out at once, because the issuer rotated its signing key and your cached copy no longer contains it.

All three have the same root: JWT verification is trivial arithmetic wrapped around a key distribution problem, and the key distribution part is what the edge makes awkward.

Root cause: no Node crypto, no persistent cache, no warning on key rotation

Edge runtimes do not implement Node’s crypto module. Libraries built on it — jsonwebtoken, jws, anything transitively depending on crypto.createVerify — cannot run, and no polyfill fully substitutes for them, as explored in polyfilling Node crypto in edge runtimes. What edge runtimes do provide is crypto.subtle, the Web Crypto API, which supports exactly the asymmetric algorithms JWTs use.

The caching problem is structural. An isolate has no disk and no cross-request store, so anything you want to reuse must live in module scope — memory that persists for the lifetime of that isolate. Module scope is not a global cache: each PoP, and each isolate within it, keeps its own copy. That is fine, because a JWKS document is small and the fetch happens once per isolate lifetime, but it means a naive await fetch(jwksUrl) inside your handler runs on every request.

Key rotation is the trap that only bites in production. Issuers rotate signing keys on a schedule, publishing the new key alongside the old one. A cache that never re-fetches will keep a stale key set and reject every token signed by the new key. The fix is not a shorter TTL — it is re-fetching when you encounter an unknown key id.

JWKS lookup and re-fetch on unknown key id The token's kid is looked up in the isolate's cached key set. A hit verifies immediately. A miss triggers a single re-fetch of the issuer JWKS, subject to a cooldown, and then verifies. Token header.kid = k7 Module-scope key set k5, k6 cached per isolate Re-fetch JWKS only on unknown kid cooldown 30 s Verify signature crypto.subtle, ~0.3 ms Identity sub, roles miss hit
The steady state is the green path: a cached key and a sub-millisecond signature check. The amber path runs once per key rotation, per isolate.

Step 1 — Install jose and create the key set at module scope

jose ships as ESM with zero dependencies and targets Web Crypto directly, so it bundles cleanly for every edge runtime.

// lib/verify.ts
import { createRemoteJWKSet, jwtVerify, errors, type JWTPayload } from 'jose';

// Module scope. Evaluated once when the isolate boots, then shared by every
// request that isolate handles. Never move this inside the handler.
const JWKS = createRemoteJWKSet(
  new URL('https://issuer.example.com/.well-known/jwks.json'),
  {
    // Minimum time between fetches, so a burst of unknown-kid tokens
    // cannot turn into a fetch storm against your issuer.
    cooldownDuration: 30_000,
    // Force a refresh after this long even without an unknown kid.
    cacheMaxAge: 10 * 60_000,
    timeoutDuration: 5_000,
  },
);

createRemoteJWKSet handles the rotation case for you: when it sees a kid it does not hold, it re-fetches — subject to cooldownDuration, which is what stops a flood of bogus tokens from becoming an accidental denial-of-service against your identity provider.

Step 2 — Verify with every constraint pinned

export interface Identity {
  userId: string;
  roles: string[];
  sessionId: string;
  expiresAt: number;
}

export type VerifyFailure =
  | 'missing' | 'malformed' | 'bad_signature'
  | 'expired' | 'wrong_issuer' | 'wrong_audience' | 'missing_claim';

export type VerifyResult =
  | { ok: true; identity: Identity }
  | { ok: false; reason: VerifyFailure };

export async function verify(token: string | null): Promise<VerifyResult> {
  if (!token) return { ok: false, reason: 'missing' };

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: 'https://issuer.example.com/',
      audience: 'edge-middleware.com',
      algorithms: ['RS256', 'ES256'],
      clockTolerance: 30,
    });

    const identity = toIdentity(payload);
    return identity
      ? { ok: true, identity }
      : { ok: false, reason: 'missing_claim' };
  } catch (err) {
    return { ok: false, reason: classify(err) };
  }
}

function classify(err: unknown): VerifyFailure {
  if (err instanceof errors.JWTExpired) return 'expired';
  if (err instanceof errors.JWTClaimValidationFailed) {
    return err.claim === 'iss' ? 'wrong_issuer'
      : err.claim === 'aud' ? 'wrong_audience'
      : 'missing_claim';
  }
  if (err instanceof errors.JWSSignatureVerificationFailed) return 'bad_signature';
  return 'malformed';
}

function toIdentity(payload: JWTPayload): Identity | null {
  const { sub, sid, exp, roles } = payload;
  if (typeof sub !== 'string' || typeof sid !== 'string' || typeof exp !== 'number') {
    return null;
  }
  return {
    userId: sub,
    sessionId: sid,
    expiresAt: exp * 1000,
    roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === 'string') : [],
  };
}

The algorithms array is the single most important line. Without it, jose will accept whatever the token’s own header claims — including an attacker switching an RS256 token to HS256 and signing it with your public key, which is not secret. Pinning the list makes that attack impossible.

The classify function exists so failures are diagnosable without leaking anything. The reason code goes to your logs; the caller only ever sees a bare 401.

Step 3 — Wire it into the middleware

// middleware.ts
import { verify } from './lib/verify';

function bearer(request: Request): string | null {
  const header = request.headers.get('authorization');
  if (header?.startsWith('Bearer ')) return header.slice(7);
  const cookie = request.headers.get('cookie') ?? '';
  const match = cookie.match(/(?:^|;\s*)__Host-session=([^;]+)/);
  return match ? decodeURIComponent(match[1]) : null;
}

export default async function middleware(request: Request): Promise<Response> {
  const result = await verify(bearer(request));

  if (!result.ok) {
    console.log(JSON.stringify({
      event: 'auth.reject',
      reason: result.reason,          // never the token itself
      path: new URL(request.url).pathname,
    }));
    return new Response(null, {
      status: 401,
      headers: { 'www-authenticate': 'Bearer', 'cache-control': 'no-store' },
    });
  }

  const headers = new Headers(request.headers);
  headers.delete('x-user-id');
  headers.delete('x-user-roles');
  headers.set('x-user-id', result.identity.userId);
  headers.set('x-user-roles', result.identity.roles.join(','));

  return fetch(new Request(request, { headers }));
}

cache-control: no-store on the rejection matters. A 401 cached at a shared tier would be served to a subsequent, properly authenticated request.

Cold versus warm verification cost On a cold isolate the JWKS fetch dominates the request, taking tens of milliseconds. On every subsequent warm request only parsing and the signature check remain, together well under a millisecond. Cold isolate JWKS fetch + parse ≈ 40 ms Warm isolate ≈ 0.4 ms total decode + claim checks crypto.subtle signature verify network Move the key set into the handler and every request becomes the top bar.
The entire optimization is moving one line out of the handler. Warm requests then never touch the network.

Step 4 — Scope the matcher

Verification on a static asset is pure waste — and on Vercel it is billed waste. Exclude everything that has no identity dimension.

export const config = {
  runtime: 'edge',
  matcher: [
    // Everything except framework assets, images, and public API routes.
    '/((?!_next/static|_next/image|favicon.ico|robots.txt|api/public).*)',
  ],
};

Local versus production divergence

Behaviour Local dev Edge production
Module scope lifetime Reset on every file save Lives for the isolate’s lifetime, often minutes
JWKS fetch frequency Effectively per request under hot reload Once per isolate, then on unknown kid
Clock Your machine, possibly drifted PoP clocks tightly synchronised
crypto.subtle Present in Node 18+ and Miniflare Present natively
Outbound fetch to issuer Direct, no egress restrictions May be subject to platform egress rules
Secure cookies Not set over http://localhost in some browsers Always available over HTTPS
Unknown kid Rare — you rarely rotate locally The realistic failure mode

The first row explains most “it was fast locally” surprises in reverse: hot reload resets module scope constantly, so local dev exercises the cold path far more than production does. If verification feels slow locally and fine in production, that is expected, not a bug.

Validating with Vitest

Test against a locally generated key pair so the suite has no network dependency. jose can both sign and verify, which makes round-trip tests straightforward.

// verify.test.ts
import { describe, expect, it } from 'vitest';
import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose';

const ISSUER = 'https://issuer.example.com/';
const AUDIENCE = 'edge-middleware.com';

async function makeToken(overrides: Record<string, unknown> = {}, expiresIn = '5m') {
  const { privateKey, publicKey } = await generateKeyPair('ES256');
  const jwk: JWK = { ...(await exportJWK(publicKey)), kid: 'test-key', alg: 'ES256' };

  const token = await new SignJWT({ sid: 'sess_1', roles: ['reader'], ...overrides })
    .setProtectedHeader({ alg: 'ES256', kid: 'test-key' })
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setSubject('u_42')
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .sign(privateKey);

  return { token, jwks: { keys: [jwk] } };
}

describe('verify', () => {
  it('accepts a well-formed token and extracts identity', async () => {
    const { token, jwks } = await makeToken();
    const result = await verifyWithKeys(token, jwks);
    expect(result).toMatchObject({ ok: true, identity: { userId: 'u_42', roles: ['reader'] } });
  });

  it('rejects an expired token with the expired reason', async () => {
    const { token, jwks } = await makeToken({}, '-1s');
    expect(await verifyWithKeys(token, jwks)).toEqual({ ok: false, reason: 'expired' });
  });

  it('rejects a token minted for another audience', async () => {
    const { token, jwks } = await makeToken();
    const result = await verifyWithKeys(token, jwks, { audience: 'other-app' });
    expect(result).toEqual({ ok: false, reason: 'wrong_audience' });
  });

  it('rejects a token whose signature does not match the key set', async () => {
    const { token } = await makeToken();
    const { jwks: otherKeys } = await makeToken();     // different key pair
    expect(await verifyWithKeys(token, otherKeys)).toEqual({ ok: false, reason: 'bad_signature' });
  });
});

Factor the verification core so it accepts an injected key set — verifyWithKeys(token, jwks, opts) — and have the production entry point supply the remote one. That keeps the test suite offline and deterministic while exercising the exact code path that runs in production.

Test matrix for token verification Four test inputs — valid token, expired token, wrong audience, and foreign key — map to the four expected outcomes: identity, expired, wrong_audience and bad_signature. valid, in-date token exp in the past aud = other-app signed by foreign key ok: true, identity reason: expired reason: wrong_audience reason: bad_signature
Asserting the specific failure reason — not just "it threw" — is what stops a signature bug from silently masquerading as an expiry bug.

Common pitfalls and resolutions

Module not found: Can't resolve 'crypto' — a Node-only JWT library is in the bundle. Replace it with jose, and check transitive dependencies; an SDK you did not think of may be pulling one in.

Every token rejected right after a key rotation — the key set is a plain object fetched once and never refreshed. Use createRemoteJWKSet, which re-fetches on an unknown kid.

Verification latency spikes under load — the key set is being constructed inside the request handler. Hoist it to module scope.

JWTClaimValidationFailed: unexpected "iss" — the issuer string must match exactly, trailing slash included. Copy it from the token, not from documentation.

Tokens valid seconds after they should have expired — an overly generous clockTolerance. Thirty seconds is the sane ceiling.

Works in one region, fails in another — a regional issuer endpoint or an egress restriction. Confirm the JWKS URL resolves identically from every PoP.

Production deployment checklist

Frequently Asked Questions

Does createRemoteJWKSet cache across requests at the edge?

Yes, provided you create it at module scope. The cache lives in isolate memory and is shared by every request that isolate handles, so a warm isolate never re-fetches unless it encounters an unknown key id or the cache max age elapses.

Why must I pass an explicit algorithms array?

Without it, verification trusts the algorithm named in the token’s own header. An attacker can change an RS256 token to HS256 and sign it with your public key, which is not a secret, and the token verifies. Pinning the list closes that attack.

Should verification failures return different status codes?

No. Return a bare 401 for every failure and keep the specific reason in your logs. Different codes or messages tell an attacker whether a token was expired, wrongly scoped or badly signed, which helps them probe your issuer.

Is ES256 or RS256 better for edge verification?

ES256 uses much smaller keys and signatures for equivalent strength, so tokens are shorter and verification is marginally cheaper. Either is fine within a normal edge CPU budget; choose whichever your identity provider issues and pin it.

How do I test verification without calling my real issuer?

Factor the core so it accepts an injected key set, then use generateKeyPair and SignJWT from jose to mint tokens and build a matching JWKS inside the test. The suite stays offline and deterministic while exercising the production code path.