Short answer
Verify the JWT’s signature locally in Next.js proxy (called middleware before v16) and use the result only for fast redirects. Then authorise again next to the data. With jose, checking a token is local CPU work with cached keys, with no database or network call per request. But proxy can be skipped, mis-matched or bypassed, as CVE-2025-29927 showed. Every Server Action, Route Handler and data read needs its own check.
“JWT auth in middleware” is one of the most-copied Next.js patterns, and most copies have two problems. They treat middleware as the security layer when it is really the routing layer. And they predate Next.js 16, which renamed the file, changed its default runtime and stated outright that this layer should not be your only line of defence. This guide is a version that stands up to review.
What changed in Next.js 16
| Next.js 15.x | Next.js 16 | |
|---|---|---|
| File | middleware.ts |
proxy.ts (middleware.ts deprecated) |
| Export | middleware (or default) |
proxy (or default) |
| Default runtime | Edge. Node.js opt-in, stable from 15.5 | Node.js. runtime config throws an error in proxy.ts |
| Edge runtime | Default | Only through the deprecated middleware.ts |
| Migration | npx @next/codemod@canary middleware-to-proxy . renames the file and the function |
|
The proxy reference gives the reason for the rename. The old name was confused with Express middleware and encouraged overuse, and the Next.js team now recommends it as a last resort. The “edge” in this article’s URL is a holdover: on Next.js 16 your auth check runs on Node.js by default, unless your platform deploys proxy closer to the user. Runtime trade-offs by host are in Deploying Next.js: Vercel, self-hosted and edge.
Why proxy cannot be your only check
CVE-2025-29927
In March 2025, Next.js published a critical advisory (GHSA-f82v-jwr5-mffw, CVSS 9.1). Next.js used an internal header, x-middleware-subrequest, to stop middleware from recursing into itself. An external request that carried the header could make the server skip middleware altogether, along with any authorisation check inside it. The advisory lists 12.x to 15.x as affected. Fixes shipped in 12.3.5, 13.5.9, 14.2.25 and 15.2.3, and 11.x users were pointed to a header-blocking workaround. According to Vercel’s postmortem, self-hosted next start and standalone deployments were exposed, while Vercel, Netlify and Cloudflare Workers were not, because their routing runs separately.
The lesson goes beyond “patch”. An app that authorised in middleware and next to its data would have been inconvenienced by that bypass. An app that authorised only in middleware was open.
The quieter ways to lose coverage
- Matchers. Server Actions are not separate routes. They are POST requests to the page that uses them. A matcher that excludes a path also skips the actions on that path, and refactoring an action into another route can silently remove proxy coverage. The Next.js docs say to verify authorisation inside each Server Function.
- Layouts. Layouts don’t re-render on navigation, so an auth check in a layout does not run on every route change. A layout that hides its children also doesn’t stop those children from being rendered into the RSC payload.
- Static routes. Data fetched at build time has no request to check. Proxy is the right tool for gating shared static content such as a paywall, which is one of the few cases where it is the main check.
The Next.js authentication guide calls proxy checks optimistic: they read the session from the cookie and redirect. Secure checks happen in a data access layer, close to the data.
Verifying a JWT without a network hop
Verifying a signed token is local work: decode, check the signature against a key, compare claims. The only network call is fetching the identity provider’s public keys (JWKS), and jose‘s createRemoteJWKSet caches those per server instance. Never call your database or identity provider in proxy. Proxy runs on every matched request, including prefetches.
Put the verification in one shared module, so proxy and the data layer check the same claims:
// lib/tokens.ts - shared by proxy.ts and the data access layer
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
// Module scope: created once per server instance. jose caches the key set and
// refetches when it sees an unknown key ID, so key rotation works.
const JWKS = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));
export async function verifyToken(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.AUTH_ISSUER, // claim must be present and match
audience: process.env.AUTH_AUDIENCE, // stops tokens minted for other apps
algorithms: ['RS256'], // pin it; never accept whatever the header says
clockTolerance: '30s', // applies to exp and nbf
});
return payload;
} catch {
return null; // expired, bad signature, wrong issuer or audience
}
}
If your app issues its own session tokens rather than using an external identity provider, use a symmetric key and pin HS256: jwtVerify(token, new TextEncoder().encode(process.env.SESSION_SECRET), { algorithms: ['HS256'] }). jose never accepts unsecured alg: "none" tokens, and its verification options make issuer, audience and token age explicit. jose runs on Web Crypto, so the same module works on the Node.js and edge runtimes.
The proxy itself
// proxy.ts (Next.js 16). On 15.x: middleware.ts and `export async function middleware`.
import { NextResponse, type NextRequest } from 'next/server';
import { verifyToken } from '@/lib/tokens';
const PUBLIC_PATHS = new Set(['/', '/login', '/signup']);
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
const token = request.cookies.get('session')?.value;
const payload = token ? await verifyToken(token) : null;
if (!payload?.sub) {
const login = new URL('/login', request.url);
login.searchParams.set('next', pathname); // validate as a relative path before using it
return NextResponse.redirect(login);
}
return NextResponse.next();
}
export const config = {
// Skip static assets and images. Route Handlers under /api answer with 401
// themselves rather than being redirected to an HTML login page.
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
};
There is one thing this proxy deliberately does not do: pass the user ID downstream in a request header. The pattern is common, but it creates a trust problem. If downstream code believes x-user-id, then any request that reaches the app without passing through proxy can claim to be anyone, whether because of a bypass, a matcher gap or a misconfigured load balancer. Pages should re-read the cookie through the data access layer. Thanks to React’s cache(), that is one verification per render.
The real check: next to the data
// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { verifyToken } from './tokens';
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value;
const payload = token ? await verifyToken(token) : null;
if (!payload?.sub) redirect('/login');
return { userId: payload.sub, role: payload.role as string | undefined };
});
// app/api/invoices/route.ts - a Route Handler is a public endpoint
import { cookies } from 'next/headers';
import { verifyToken } from '@/lib/tokens';
export async function GET() {
const token = (await cookies()).get('session')?.value;
const payload = token ? await verifyToken(token) : null;
if (!payload?.sub) return new Response(null, { status: 401 });
if (payload.role !== 'finance') return new Response(null, { status: 403 });
// ...query scoped to payload.sub
}
Server Actions get the same treatment: call verifySession() at the top of every action, and validate its input as well. Zod contracts for Server Actions covers the input side, and App Router patterns explains why an action is effectively a public endpoint.
Expiry, revocation and refresh
A stateless JWT stays valid until it expires, whatever happens to the account. Local verification cannot tell you that a user was suspended five minutes ago. There are three ways to live with that:
- Short access tokens (minutes, not days) with a refresh token. Do the refresh in a Route Handler or Server Action, not in proxy, so you aren’t calling the identity provider on every navigation.
- A database or session-store check in the data access layer for sensitive operations (payments, role changes, data export), while the cheap check in proxy stays for routing.
- A per-user token version stored server-side and embedded in the token as a claim. Incrementing it revokes every outstanding token at the next secure check.
Hardening checklist
- Run a patched Next.js. Anything older than 12.3.5, 13.5.9, 14.2.25 or 15.2.3 is affected by CVE-2025-29927. If you cannot upgrade immediately, strip
x-middleware-subrequestfrom external requests at your load balancer or CDN. - Authorise in the data access layer, every Server Action and every Route Handler. Treat proxy as UX.
- Pin
algorithms, and requireissuerandaudience, in one shared verify function. - Set session cookies on the server with
HttpOnly,Secure,SameSite=Lax(orStrict) and a sensible expiry. - No database, identity-provider or other network calls in proxy apart from the cached JWKS fetch.
- Never trust identity headers that a client could send. Strip them at the edge, or don’t use them.
- Validate any
next/return-to parameter as a relative path, to avoid an open redirect. - Rename
middleware.tstoproxy.tswith the codemod when you move to Next.js 16, and check that your auth library supports the Node.js runtime.
Questions people actually ask
- Can I still run auth middleware on the Edge runtime in Next.js 16?
- Only through the deprecated
middleware.ts, which Next.js says will be removed in a future version.proxy.tsruns on Node.js, and setting aruntimeoption there throws an error. Becausejoseworks on both runtimes, the verification code does not need to change. - Was my app affected by CVE-2025-29927?
- If it was self-hosted (
next startor standalone output), ran a version before 12.3.5, 13.5.9, 14.2.25 or 15.2.3, and did authorisation in middleware, then yes. Upgrade, and check for any route that depended on middleware alone. - Should I verify the JWT in proxy or in the page?
- Both, for different reasons. Proxy gives an early redirect for unauthenticated users. The page, action or handler makes the authorisation decision. A shared
verifyTokenfunction keeps the two from drifting apart.
Is your auth relying on middleware alone?
I review Next.js authentication flows end to end: proxy matchers, Server Actions, Route Handlers and token handling. You get a list of the gaps a bypass would expose and how to close them.