Authenticating Requests in Vercel Edge Middleware

After working through this guide you will be able to verify a session cookie or JWT inside middleware.ts — before Next.js renders a single component — using only Web Crypto, cache the issuer’s JWKS in isolate memory behind a TTL, bounce unauthenticated visitors to a login page that remembers where they were going, and hand the verified claims to your route handlers through a request header the client cannot forge.

Doing the check in middleware buys two things a server component check cannot: the rejection happens in the PoP nearest the user rather than in a render region an ocean away, and no protected code path ever executes with an unverified identity. What it costs you is a runtime with no Node APIs, a hard CPU budget measured in tens of milliseconds, and a matcher that is easy to get subtly wrong. This guide is the Vercel counterpart to JWT validation at the edge with Cloudflare Workers; the cryptography is the same, the framework integration and failure modes are not.

Key objectives:

  • Verify an RS256 token with crypto.subtle and reject on signature, exp, iss or aud failure.
  • Keep JWKS fetches off the hot path with a module-scoped memo and a TTL that survives key rotation.
  • Redirect a browser while preserving the intended destination, and answer API routes with 401 instead.
  • Pass verified claims downstream in a header that is stripped from the inbound request first.

Decide what belongs in middleware before you write any of it

Middleware is a gate, not an auth server. It answers one question — is this request carrying a currently valid credential — and everything else belongs elsewhere. Getting this boundary right is what keeps you inside the CPU budget.

Concern Middleware (edge) Route handler / server action
Signature and claim verification Yes — pure crypto, no I/O after warm-up Redundant
Session lookup in a database No — network I/O per request Yes
Refresh-token rotation No — needs a write and Set-Cookie on a POST Yes
Login form, password check, MFA No Yes
Coarse role gate (role !== "admin" → 403) Yes, from claims already verified Optional second check
Per-object authorization No — needs your data model Yes

The pattern that follows from the table: middleware verifies a stateless credential, and anything requiring state or a write happens in a normal route handler that middleware has already gated.

Authentication decision flow in edge middleware A request passes the matcher, then a cookie presence check, then token verification, and either reaches the app with claims attached or is redirected or rejected. Incoming request matcher in scope? config.matcher _next, assets, favicon function never runs session cookie? HttpOnly, Secure 307 to /login ?next=/original/path verify signature crypto.subtle + JWKS clear cookie 401 JSON or /login next() with x-auth-claims page or route handler renders skip no fail valid

Prerequisites and environment setup

You need a Next.js 13.4+ application on Vercel, the Vercel CLI, and an identity provider that publishes a JWKS document. Auth0, Clerk, Okta, Cognito, Keycloak and every other OIDC issuer expose one under /.well-known/jwks.json.

node --version       # v18.18 or later
npx vercel --version # 33.x or newer
npx vercel link      # associate the local dir with the project

Put the issuer configuration in environment variables rather than in the source, and pull them locally so vercel dev behaves like production:

npx vercel env add AUTH_ISSUER production      # https://tenant.example-idp.com/
npx vercel env add AUTH_AUDIENCE production    # your API or app identifier
npx vercel env add AUTH_JWKS_URL production    # issuer + /.well-known/jwks.json
npx vercel env pull .env.local

One runtime constraint drives every decision below: the Edge Runtime exposes Web APIs only. There is no require("crypto"), no jsonwebtoken, no node:buffer. Pulling in a library that reaches for those fails the build outright, which is a good failure — the bad case is a library that ships an isomorphic shim and quietly inflates your bundle past the ~1 MB ceiling.

Step 1 — Scope the matcher so it never sees an asset

Every request the matcher admits is an invocation you pay for and a few milliseconds you spend. Start from a deny-everything-static regex and add your protected trees explicitly.

export const config = {
  matcher: [
    // Protected app surfaces only; everything else skips the function entirely.
    '/dashboard/:path*',
    '/settings/:path*',
    '/api/private/:path*',
  ],
};

If you prefer a single broad matcher, exclude the static trees by negative lookahead instead:

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|login|api/public).*)'],
};

Two traps live here. The matcher is evaluated against the path before any rewrite, so a rewritten path does not re-enter the function. And a matcher entry must be a literal or a static pattern — it is read at build time, so an expression built from a variable silently produces nothing. Scoping is the same discipline applied in A/B testing with Vercel Edge Middleware, where an over-broad matcher buckets asset requests.

Read the credential with the typed cookie API rather than parsing the raw header. The attributes on the cookie matter more than the code that reads it:

import { NextRequest } from 'next/server';

const COOKIE = '__Host-session';

function readToken(req: NextRequest): string | null {
  const raw = req.cookies.get(COOKIE)?.value;
  if (!raw) return null;
  // Cheap structural check before spending CPU on crypto.
  return raw.split('.').length === 3 ? raw : null;
}

The __Host- prefix is worth adopting: browsers only accept such a cookie when it is Secure, has Path=/, and carries no Domain attribute, which makes it impossible for a sibling subdomain to write it. Pair it with HttpOnly so no script can read the token, and SameSite=Lax so it rides along on top-level navigations but not on cross-site form posts. Keep the token small: a cookie over roughly 4 KB starts running into header limits, and a bloated claim set is the usual cause.

Step 3 — Verify the signature with Web Crypto

Import the JWK, verify over the raw header.payload bytes, then check the claims. Pin the algorithm rather than reading it from the token — the token’s own alg field is attacker-controlled and trusting it is the classic algorithm-confusion hole.

const ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } as const;
const SKEW = 60; // seconds of tolerated clock drift

function b64url(seg: string): Uint8Array {
  const b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
  const pad = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=');
  return Uint8Array.from(atob(pad), (c) => c.charCodeAt(0));
}

function decode(seg: string): any {
  return JSON.parse(new TextDecoder().decode(b64url(seg)));
}

export async function verify(token: string): Promise<Record<string, any>> {
  const [h, p, s] = token.split('.');
  const header = decode(h);
  if (header.alg !== 'RS256') throw new Error('alg_not_allowed');

  const jwk = await getKey(header.kid);
  const key = await crypto.subtle.importKey('jwk', jwk, ALG, false, ['verify']);
  const ok = await crypto.subtle.verify(
    ALG.name,
    key,
    b64url(s),
    new TextEncoder().encode(`${h}.${p}`),
  );
  if (!ok) throw new Error('bad_signature');

  const claims = decode(p);
  const now = Math.floor(Date.now() / 1000);
  if (typeof claims.exp !== 'number' || now > claims.exp + SKEW) throw new Error('expired');
  if (typeof claims.nbf === 'number' && now + SKEW < claims.nbf) throw new Error('not_yet_valid');
  if (claims.iss !== process.env.AUTH_ISSUER) throw new Error('bad_issuer');
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (!aud.includes(process.env.AUTH_AUDIENCE)) throw new Error('bad_audience');
  return claims;
}

Expected behavior: a well-formed, in-date token resolves to its claim object in roughly one to three milliseconds of CPU once the key is warm. Every failure throws a named error, which becomes your log line and your metric label.

Step 4 — Cache the JWKS in isolate memory

Fetching the JWKS per request adds a full network round trip to the auth path and will eventually get you rate-limited by your issuer. A module-scoped variable persists for the lifetime of the isolate, which serves many requests, so a TTL-guarded memo removes almost all of those fetches.

type Jwks = { keys: any[] };
let memo: { keys: any[]; at: number } | null = null;
const TTL_MS = 10 * 60 * 1000;

async function loadJwks(): Promise<any[]> {
  const res = await fetch(process.env.AUTH_JWKS_URL!, { cache: 'no-store' });
  if (!res.ok) throw new Error(`jwks_${res.status}`);
  const { keys } = (await res.json()) as Jwks;
  memo = { keys, at: Date.now() };
  return keys;
}

async function getKey(kid: string, retried = false): Promise<any> {
  if (!memo || Date.now() - memo.at > TTL_MS) await loadJwks();
  const jwk = memo!.keys.find((k) => k.kid === kid);
  if (jwk) return jwk;
  if (retried) throw new Error(`unknown_kid_${kid}`);
  memo = null;                 // a rotation happened mid-TTL
  return getKey(kid, true);
}
JWKS cache behavior over an isolate lifetime A timeline showing one fetch on isolate boot, in-memory hits for the TTL window, and a single forced refetch when a token arrives with an unknown key id. Isolate boot one fetch, ~40 ms TTL window memory hits, ~0 ms Unknown kid one forced refetch t = 0 t < 10 min issuer rotates key The kid-miss refetch is what makes rotation a non-event — do not remove it Guard it with the retry flag or a bad kid becomes an infinite loop

The retry flag is load-bearing twice over. Without it, an attacker sending tokens with random kid values turns every request into a JWKS fetch — a cheap amplification attack against your issuer. With it, each unknown key costs exactly one refetch and then fails closed.

Step 5 — Redirect, but keep the destination

An unauthenticated browser should land on the login page and, after signing in, arrive where it was originally going. Encode that destination as a query parameter and validate it on the way back out, or you have shipped an open redirect.

import { NextResponse } from 'next/server';

function toLogin(req: NextRequest) {
  const url = req.nextUrl.clone();
  const dest = req.nextUrl.pathname + req.nextUrl.search;
  url.pathname = '/login';
  url.search = '';
  // Only ever a same-origin, absolute path — never a full URL from the client.
  url.searchParams.set('next', dest.startsWith('/') && !dest.startsWith('//') ? dest : '/dashboard');
  const res = NextResponse.redirect(url, 307);
  res.headers.set('Cache-Control', 'private, no-store');
  return res;
}

Use 307, not 302: it preserves the method, so a POST that arrives with an expired session is not silently downgraded to a GET. The no-store is not optional — a cached redirect on a public path is how one visitor’s auth bounce becomes everyone’s, a hazard covered in the caching notes on the parent Vercel Edge Middleware page.

Step 6 — Pass claims downstream without creating a forgery hole

Route handlers should not re-parse the token. Give them the verified claims in a request header — but delete any inbound copy of that header first, because a client can send x-auth-claims: {"role":"admin"} on its very first request.

export async function middleware(req: NextRequest) {
  const isApi = req.nextUrl.pathname.startsWith('/api/');
  const token = readToken(req);
  if (!token) return isApi ? unauthorized('missing_token') : toLogin(req);

  let claims: Record<string, any>;
  try {
    claims = await verify(token);
  } catch (err) {
    const reason = (err as Error).message;
    console.log('auth reject', reason, req.nextUrl.pathname);
    if (isApi) return unauthorized(reason);
    const res = toLogin(req);
    res.cookies.set(COOKIE, '', { path: '/', maxAge: 0 });  // drop the dead cookie
    return res;
  }

  // Rebuild the inbound headers: strip anything spoofable, then set our own.
  const headers = new Headers(req.headers);
  headers.delete('x-auth-claims');
  headers.delete('x-auth-sub');
  headers.set('x-auth-sub', String(claims.sub));
  headers.set('x-auth-claims', JSON.stringify({ sub: claims.sub, role: claims.role ?? 'user', exp: claims.exp }));

  return NextResponse.next({ request: { headers } });
}

function unauthorized(reason: string) {
  return new NextResponse(JSON.stringify({ error: 'unauthorized', reason }), {
    status: 401,
    headers: {
      'content-type': 'application/json',
      'cache-control': 'private, no-store',
      'www-authenticate': 'Bearer error="invalid_token"',
    },
  });
}
Claims header trust boundary A client sends a forged claims header, middleware deletes it and writes its own value from verified claims, and the route handler consumes only the trusted header. Only the layer that verified may write the claims header Client request Cookie: __Host-session x-auth-claims: forged middleware.ts 1. delete inbound copy 2. verify the token 3. set from claims Route handler reads x-auth-claims no token parsing untrusted trusted Skip the delete and any client is an admin the header is only as trustworthy as its writer

The same rule applies to any enrichment header you add here; the safe injection patterns are covered under modifying request headers at the CDN edge layer. If a second CDN sits in front of Vercel, strip the header there too — otherwise the outermost hop is the real trust boundary and it is not the one doing the verifying.

Refresh tokens and the CPU budget

Do not rotate refresh tokens in middleware. Rotation is a write: it needs a Set-Cookie on the response, usually an issuer round trip, and often a database update to invalidate the old token — and middleware runs on every matched request, so a race between two parallel requests can burn a single-use refresh token and log the user out. The workable pattern is: keep access tokens short (5 to 15 minutes), let middleware fail closed when one expires, and put refresh on a dedicated POST /api/auth/refresh route handler that the client calls when it sees a 401. Middleware stays read-only and idempotent.

The budget makes the same argument. Middleware has roughly 50 ms of wall CPU, and a warm verification uses a small fraction of it — but a JWKS fetch on a cold isolate, a large claim set, or a JSON parse of an oversized cookie all eat into it, and an overrun surfaces as a 500 that reproduces only under load. Measure it: log Date.now() deltas around verify() for a week before you add anything else to the function.

Verification

Start with the unauthenticated case. A browser path should redirect and carry the destination:

curl -sI https://your-app.vercel.app/dashboard/reports | grep -iE 'HTTP/|location|cache-control'
HTTP/2 307
location: /login?next=%2Fdashboard%2Freports
cache-control: private, no-store

An API path under the same matcher must answer with JSON rather than a redirect, so a fetch client sees a real status:

curl -s -o /dev/null -w '%{http_code}\n' https://your-app.vercel.app/api/private/me
# 401

Now replay with a valid token and confirm the request reaches the app:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Cookie: __Host-session=$VALID_JWT" \
  https://your-app.vercel.app/api/private/me
# 200

Prove the spoofing guard by sending your own claims header alongside a valid session; the handler should echo the verified subject, never the injected one:

curl -s -H "Cookie: __Host-session=$VALID_JWT" \
     -H 'x-auth-claims: {"role":"admin"}' \
     https://your-app.vercel.app/api/private/me
# {"sub":"auth0|1234","role":"user"}

Then walk the browser flow end to end: open a protected URL in a private window, sign in, and confirm you land back on the original path rather than a generic dashboard. Watch it happen server-side while you do:

npx vercel logs --follow | grep 'auth reject'

Troubleshooting

Every token fails right after a key rotation

Symptom: a burst of unknown_kid rejections that clears itself in ten minutes. The memo held the old key set past the rotation. Confirm by logging header.kid and comparing it with the live document (curl -s "$AUTH_JWKS_URL" | grep -o '"kid":"[^"]*"'). The fix is the kid-miss refetch in step 4 — if it is present and you still see the burst, check that memo = null runs before the recursive call, and that fetch is not being served from a runtime cache (cache: 'no-store' matters here).

Middleware never runs on the path you expected

Symptom: no log lines, no redirect, the page renders for anonymous users. Add a marker header on the pass-through branch and check for it: curl -sI <url> | grep -i x-mw. If it is absent the matcher did not admit the path. The usual causes are a missing :path* suffix (/dashboard matches only the exact path, not /dashboard/reports), a middleware.ts in the wrong directory — it belongs at the project root, or inside src/ if that is your source root, never inside app/ — and matcher entries built dynamically at runtime, which are evaluated at build time and produce nothing.

Redirect loop between the app and the login page

Symptom: the browser reports too many redirects; curl -sL -o /dev/null -w '%{num_redirects}\n' <url> returns a large number. Almost always the login page itself is inside the matcher, so an anonymous visitor to /login is redirected to /login. Exclude the login route and the auth callback from the matcher, and add a runtime guard (if (req.nextUrl.pathname === '/login') return NextResponse.next()) as a backstop. The second cause is a login page that redirects to next before the cookie is actually set, sending the user straight back around.

Valid tokens rejected as expired

Symptom: auth reject expired for tokens the issuer minted seconds ago. Decode and compare against wall clock:

echo "$VALID_JWT" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
date +%s

If exp is comfortably in the future, the drift is on the issuing side or your SKEW is zero. Keep SKEW at 60 seconds — enough for realistic drift, small enough that expiry still means something. If the token’s whole lifetime is shorter than the skew window you have a token-lifetime problem, not a clock problem.

The claims header is spoofable from the client

Symptom: a request with a hand-written x-auth-claims and no cookie reaches a handler that trusts it. Either the headers.delete() calls are missing, or the route is outside the matcher so middleware never ran, or a proxy in front of Vercel is adding the header. Test it directly with the curl above; the correct response to a forged header with no valid session is a 401 or a redirect, never a 200. Also confirm that no route handler falls back to reading claims from the query string during local development — that shortcut has a habit of shipping.

Frequently Asked Questions

Why verify in middleware when the page already checks the session? Because middleware rejects before any application code runs, in the PoP closest to the user, which removes both the render cost and the round trip to a distant region. It also gives you one enforcement point instead of a per-page check that someone eventually forgets on a new route.

Can I use a Node JWT library in the Edge Runtime? Only if it is written against Web APIs. Anything importing crypto, buffer or stream from Node fails the build, and isomorphic shims tend to blow the bundle size limit. Verifying RS256 directly with crypto.subtle is about forty lines and has no dependency to audit.

Should refresh-token rotation happen at the edge? No. Rotation is a stateful write, and middleware runs on every matched request, so parallel requests can consume a single-use refresh token and log the user out. Keep access tokens short, fail closed in middleware, and rotate in a dedicated route handler the client calls on 401.

How do I protect API routes and pages with one function? Branch on the path prefix: redirect browsers with a 307 that carries the destination, and answer API paths with a 401 JSON body plus WWW-Authenticate. A fetch client that receives a redirect to an HTML login page will report a confusing parse error instead of an auth failure.

What happens if verification pushes the function over the CPU budget? The request terminates with a 500, and because the limit is input-dependent it tends to appear only under load. A warm verification is a few milliseconds, so the realistic causes are a cold JWKS fetch, an oversized cookie, or extra work added to the same function — measure each branch before adding anything, and move heavy logic into a route handler.

Back to Vercel Edge Middleware