Next.js 16 Middleware: authorization patterns that scale and the ones that cause race conditions
Next.js middleware is basically the bouncer at a club. It doesn't decide if you're welcome inside — that's the staff's job. But it does decide whether you get through the door. And if the bouncer starts running a full background check on every person before opening up, the line wraps around the block.
That's exactly the problem with authorization patterns in Next.js 16 Middleware. Most of the examples floating around online assume you can do full token validation at the edge. The reality is more uncomfortable: the edge runtime has concrete restrictions, and several patterns that worked fine in v14 blow up in production in ways that aren't obvious.
My thesis: Next.js 16 middleware is powerful, but its strength is in verifying session, not in validating a complete token. When you confuse those two roles, you end up with race conditions or latency you don't understand until you're staring at logs at 11pm.
The real problem: edge runtime is not Node.js
Before looking at each pattern, there's one fact that shapes everything that follows: Next.js middleware runs on edge runtime, not full Node.js. That's not a minor detail — it's the reason certain patterns fail.
The edge runtime has access to standard web APIs (Request, Response, Headers, crypto.subtle) but does not have access to:
fs— no reading files- native Node.js modules
- libraries that depend on Node buffers or system APIs
What this means for auth is concrete: if your JWT library uses jsonwebtoken with Node's crypto, it won't work in middleware. You need jose or another library compatible with the Web Crypto API.
// ❌ This blows up in edge runtime
import jwt from 'jsonwebtoken' // depends on Node's crypto
// ✅ This works in edge runtime
import { jwtVerify } from 'jose' // Web Crypto API compatibleThe official Next.js Middleware docs mention this, but between all the code examples it's easy to skip over that part — until the error shows up in your deploy.
The 4 patterns: tradeoff analysis
Pattern 1 — Full token validation in middleware
The most tempting one and the most problematic.
The idea: grab the token from the cookie or Authorization header, cryptographically verify it in middleware, and decide whether the user gets through.
// middleware.ts
import { jwtVerify } from 'jose'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
// Full cryptographic verification on every request
const { payload } = await jwtVerify(token, SECRET)
// Pass the userId downstream via header
const response = NextResponse.next()
response.headers.set('x-user-id', payload.sub as string)
return response
} catch {
return NextResponse.redirect(new URL('/login', request.url))
}
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}The honest tradeoff: jwtVerify with jose does run in edge runtime. The cryptographic verification itself is fast. The problem shows up when the token has a short expiration and you also need to consult a revocation list, or when you want to verify granular permissions that live in a database. At that point you're in trouble, because doing a database fetch from middleware on every request is latency that adds up.
When it works well: long-lived tokens, no active revocation, where you only need to know if the token is structurally valid.
When it blows up: if your system revokes tokens (real logout, password change), this pattern won't reflect that until the token expires on its own.
Pattern 2 — Role-based redirects in middleware
This pattern looks simple but hides a race condition specific to the Next.js App Router.
// middleware.ts — role-based redirect pattern
export async function middleware(request: NextRequest) {
const sessionCookie = request.cookies.get('session')?.value
if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Decoding without verifying — just to read the role from the payload
// ⚠️ IMPORTANT: this is NOT a security verification
const parts = sessionCookie.split('.')
if (parts.length !== 3) {
return NextResponse.redirect(new URL('/login', request.url))
}
const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString()
)
const { pathname } = request.nextUrl
// Redirect based on role
if (pathname.startsWith('/admin') && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/403', request.url))
}
return NextResponse.next()
}The race condition problem: if you use NextResponse.redirect in middleware at the same time the client has a Server Component doing a fetch from layout.tsx, you can end up with two in-flight requests pointing to different destinations. The App Router has its own navigation mechanism and the middleware redirect interrupts the hydration cycle in ways that aren't always predictable.
The symptom: the user sees a content flash before the redirect, or gets stuck in a redirect loop on certain routes. Reproducible when the matcher covers routes with nested layouts that do their own fetching.
The fix: use NextResponse.rewrite instead of redirect for internal or API routes, and save redirect only for the "no session at all" case. For granular permissions within a valid session, delegate the decision to the Server Component or Route Handler — they have full database access.
Pattern 3 — API route protection only in middleware
This is the pattern I see recommended most often in tutorials, and it has the most expensive hidden cost.
The idea is to use the matcher to protect all /api/ routes from middleware and not validate anything inside the route handler itself.
// middleware.ts — API protection from middleware only
export const config = {
matcher: ['/api/:path*'],
}
export async function middleware(request: NextRequest) {
const token = request.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
return new NextResponse(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
}
// Verify token and let through
try {
await jwtVerify(token, SECRET)
return NextResponse.next()
} catch {
return new NextResponse(
JSON.stringify({ error: 'Invalid token' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
}
}The problem: this pattern assumes middleware is the only security layer. If you ever call a route handler directly and internally — Server Action, server-side fetch, another route handler — middleware doesn't intervene. That silent bypass is the security vector that costs the most to discover.
The real cost: middleware as the sole gatekeeper works if every single access path goes through the same door. In App Router, with Server Actions and server-side calls, that assumption doesn't always hold.
My rule: middleware protects the perimeter. Route handlers validate their own authorization. Both layers need to exist — it's not one or the other. If that sounds redundant, it's the kind of redundancy worth having.
Pattern 4 — Middleware composition
Next.js 16 doesn't have native nested middleware — there's one single middleware.ts file. To compose logic, the common pattern is manually chaining functions.
// middleware.ts — manual composition
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Each function returns NextResponse or null (to continue the chain)
type MiddlewareFn = (req: NextRequest) => NextResponse | null | Promise<NextResponse | null>
// Checks that a session exists
function withSession(req: NextRequest): NextResponse | null {
const session = req.cookies.get('session')?.value
if (!session) {
return NextResponse.redirect(new URL('/login', req.url))
}
return null // continue
}
// Blocks admin routes for non-admins
function withAdminGuard(req: NextRequest): NextResponse | null {
if (!req.nextUrl.pathname.startsWith('/admin')) return null
const session = req.cookies.get('session')?.value
if (!session) return null // withSession already handled this
const parts = session.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
if (payload.role !== 'admin') {
return NextResponse.redirect(new URL('/403', req.url))
}
return null
}
// Composition function
function compose(...fns: MiddlewareFn[]) {
return async (req: NextRequest): Promise<NextResponse> => {
for (const fn of fns) {
const result = await fn(req)
if (result) return result // short-circuit on first result
}
return NextResponse.next()
}
}
export const middleware = compose(withSession, withAdminGuard)
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}The tradeoff with this pattern: it's clean and scalable, but it has a maintenance cost. Each function in the pipeline decodes the token independently — if you have 4 guards that all read the same cookie, you're parsing the JWT 4 times per request.
The concrete optimization: parse the token once at the start and pass the payload as context through headers or an augmented request object. But Next.js has no native context mechanism between middleware functions, so the tradeoff is parsing multiple times vs. coupling the parsing to the start of the pipeline.
The gotchas nobody documents well
Buffer.from in edge runtime: in some edge deployments (Vercel Edge, Cloudflare Workers), Buffer isn't available globally. If you decode JWTs with Buffer.from(..., 'base64url'), your middleware can work locally and blow up in production. The portable alternative:
// Portable base64url decoding for edge runtime
function decodeJWTPayload(token: string): Record<string, unknown> {
const base64 = token.split('.')[1]
.replace(/-/g, '+')
.replace(/_/g, '/')
const json = atob(base64) // atob is available in Web APIs
return JSON.parse(json)
}The matcher and static routes: middleware runs on every request that matches, including static assets if the matcher isn't defined carefully. A poorly written matcher can run auth logic on .ico, .png, and font files. This isn't a bug — it's a silent CPU cost at the edge.
// recommended matcher: explicitly excludes assets
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.png|.*\\.svg).*)',
],
}Race condition with new session cookies: if middleware does a redirect at the same time the client is trying to write a new session cookie (e.g. right after login), the redirect can clear the cookie before it's persisted. Reproducible in login flows with an immediate redirect before the cookie is confirmed on the client.
The pattern I'd adopt in a new system
After analyzing all four, the one that best balances security, performance, and maintainability is a hybrid approach:
- Middleware: verifies session existence (is there a token? does it look like a JWT?) and redirects if there's nothing there. No full cryptographic verification in middleware if active revocation is involved.
- Server Components / Route Handlers: verify the full token with
joseand check granular permissions if needed. - Restrictive matcher: app routes only, never static assets.
// middleware.ts — the pattern I'd use today
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Public paths that don't require a session
const PUBLIC_PATHS = ['/', '/login', '/register', '/api/auth']
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(path =>
pathname === path || pathname.startsWith(path + '/')
)
}
function hasSessionShape(token: string): boolean {
// Shape check only, not cryptographic
// Real verification happens in the route handler or server component
const parts = token.split('.')
return parts.length === 3 && parts.every(p => p.length > 0)
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Public paths: always let through
if (isPublicPath(pathname)) {
return NextResponse.next()
}
const sessionToken = request.cookies.get('session')?.value
// No token: redirect to login
if (!sessionToken || !hasSessionShape(sessionToken)) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// Token with valid shape: let through
// Cryptographic verification and permission checks happen downstream
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(png|svg|jpg|ico)).*)',
],
}This pattern is deliberately conservative: middleware does only what it can do well in edge runtime (checking the existence and shape of the token), and delegates real authorization to layers that have full access to the tools they need.
What you can't conclude without your own experiment
I'll be direct about the limits of this analysis:
- Real latency per pattern: I don't have my own public production numbers comparing these 4 patterns in real scenarios. If you want to measure it, instrument with
console.timein local middleware and compare with Edge Functions Logs in Vercel. - Behavior on Cloudflare Workers: Next.js 16 deployed on Workers can have edge runtime differences compared to Vercel Edge. The official docs cover the guaranteed subset; the rest depends on the provider.
- Session cookie race condition across all browsers: the new session + immediate redirect race condition is reproducible under specific conditions. It's not universal — it depends on client timing and hosting provider.
What is backed by official documentation: the edge runtime limitations, unavailable modules, and matcher behavior are all described in Next.js Docs — Middleware and Next.js Docs — Edge Runtime.
FAQ — Common questions about Next.js 16 Middleware and authorization
Can I use jsonwebtoken in Next.js 16 middleware?
Not reliably. jsonwebtoken depends on Node.js's crypto module, which isn't available in edge runtime. The recommended alternative is jose, which uses Web Crypto API and works at the edge. Always check dependency compatibility against the official Edge Runtime APIs list.
Does Next.js 16 middleware replace validation in route handlers? No, and thinking it does is a mistake. Middleware protects the external perimeter of the app. Route handlers can be invoked internally (Server Actions, server-side fetch) without going through middleware. If you only protect in middleware, you have a silent bypass on the internal surface.
When does it make sense to do full cryptographic verification in middleware?
When the token has no active revocation and the library is edge runtime compatible (jose). If you need to query a database to verify whether a token was revoked, that cost on every request scales badly. In that case, verify the shape in middleware and do the real check downstream.
Why can redirect loops appear in App Router with middleware?
The App Router has its own navigation system with prefetching. A NextResponse.redirect in middleware can interfere with prefetched requests, creating cycles if the redirect condition also gets evaluated at the destination. The practical rule: use redirect only for "no session", and rewrite or headers to communicate state to the rest of the system.
Does the matcher affect performance even if middleware does nothing?
Yes. Every request that matches executes the middleware, even if it immediately does NextResponse.next(). A too-broad matcher that includes static assets adds unnecessary overhead. The negative regex exclusion pattern ((?!_next/static|...)) is the correct way to limit scope.
Does it make sense to compose middlewares in Next.js 16 without native support? It makes sense if the project grows in auth complexity (multiple roles, multiple protected paths). The cost is parsing the JWT in each pipeline function. The optimization is parsing once at the start and passing the result as an internal header. If the project is simple, a well-commented monolithic middleware is more maintainable than a chain of functions.
The middleware is not your primary authorization layer
My position is uncomfortable for anyone who learned Next.js from "protect your app in 10 minutes" tutorials: middleware is excellent for doing the cheapest check of all — does this look like a token? — and redirecting fast when there's nothing there. It's a presence guard, not an auditor.
Real authorization — permissions, roles, revocation, access to specific resources — belongs in layers that have full access to the tools you actually need: Server Components, Route Handlers, Server Actions. Those layers run on full Node.js, have database access, and can use any library.
The uncomfortable part is that this split requires you to write validation in two places. But the alternative — putting all the logic in middleware and trusting that edge runtime has everything you need — is the recipe for every problem I described above.
If you're working with TypeScript strict mode in the same project, the post on the tsconfig options that impact production the most has complementary context. And if you're thinking about App Router caching alongside auth, the Next.js App Router caching post covers the interactions you need to understand before mixing the two.
The concrete next step: open your own middleware.ts, look at what it's actually doing, and ask yourself whether each operation belongs in edge or in Node.js. The answer to that question defines how well the system scales when traffic grows.
Sources:
Related Articles
Prisma 5 → Prisma 6: The Breaking Changes I Hit in My Real Schema and How I Fixed Them Without Breaking Production
Prisma 6 improves ergonomics and performance, but there are three behavior changes that won't scream at you in the compiler — and will absolutely show up at runtime if you don't audit your relational queries first. Practical guide with checklist.
tsgo: what changes in the TypeScript compiler rewritten in Go and what it means for real projects
tsgo is real and the performance jump is verifiable — but the beta has documented limits that most posts quietly ignore. Here's the concrete criteria for deciding whether to explore it today or wait for stable.
React 19 use() hook and Suspense: when it replaces useEffect and when it throws you into a worse loop
React 19's use() hook promises to replace useEffect for data fetching. That promise is partially true. There are two patterns with Suspense and error boundaries where the behavior isn't what you expect and the cycle gets messier. I'll tell you exactly when to migrate and when not to.
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.