Middleware on the Edge: The Auth Pattern That Kept Biting Us in Production
Middleware feels like the obvious place to gate auth in the App Router. Then the cold starts, cookie races, and stale sessions show up. Here's what we changed after shipping this to real traffic.
Middleware in the App Router looks like the perfect place to put authentication. It runs before the request hits your routes, it's fast, and Vercel's docs make it feel like a solved problem. Then you ship it to real users on flaky networks, watch a support ticket about a logged-in user seeing a stranger's dashboard, and start reading the runtime docs a lot more carefully.
This is a breakdown of what actually broke when we used edge middleware for auth across a handful of production Next.js apps, and the patterns we now reach for by default.
Why middleware is so tempting for auth
The pitch is clean. One file, one function, runs on every matched request, redirects unauthenticated users before any React ever renders. You avoid the flash of protected content, you keep auth logic out of every layout, and you get edge-region latency for free.
A first pass usually looks like this:
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { verifySession } from '@/lib/session'
export async function middleware(req: NextRequest) {
const token = req.cookies.get('session')?.value
const session = token ? await verifySession(token) : null
if (!session && req.nextUrl.pathname.startsWith('/app')) {
const url = req.nextUrl.clone()
url.pathname = '/login'
url.searchParams.set('next', req.nextUrl.pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/app/:path*', '/api/private/:path*'],
}
This works. It also hides four production problems that will find you within a month.
The four things that actually bite
1. The edge runtime is not Node
Middleware runs on the edge runtime by default, which is a Web-standard subset. No crypto module, no fs, no native Node APIs, and a much stricter bundle size ceiling. If your session library reaches for jsonwebtoken, bcrypt, or anything that pulls in Node built-ins, you'll either get a build error or a runtime error that only shows up in preview.
The fix is to use Web Crypto directly, or a library explicitly built for the edge (jose for JWTs is the safe pick):
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.SESSION_SECRET!)
export async function verifySession(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
})
return payload as { sub: string; exp: number }
} catch {
return null
}
}
And critically: do not do a database call in middleware. Even if your ORM claims edge support, you're adding a round trip to every navigation, every prefetch, every image request that matches your matcher. Which brings us to the next one.
2. The matcher is broader than you think
A matcher like /app/:path* matches page requests, but also RSC payload requests, prefetches triggered by <Link>, and any static assets served under that path. On a page with ten links, the browser can fire ten prefetches on hover or viewport entry. Each one runs your middleware.
If your middleware does anything more than a cookie read and a JWT verify, you've just multiplied your auth traffic by an order of magnitude. We had a case where a marketing page with a nav bar linking to /app/* was silently generating hundreds of session verifications per user session, because every prefetch hit the middleware.
Two things helped:
- Narrow the matcher aggressively and exclude asset paths
- Skip work early for RSC requests when the outcome won't change the response
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/public).*)',
],
}
export async function middleware(req: NextRequest) {
// Cheap early exits before any crypto work
const { pathname } = req.nextUrl
if (!pathname.startsWith('/app')) return NextResponse.next()
// ...
}
3. Cookie races on login and logout
This is the one that caused the "stranger's dashboard" bug. The sequence looked like this:
- User submits login form via a server action
- Server action calls
cookies().set('session', newToken, ...) - Action returns, client navigates to
/app/dashboard - Middleware reads the cookie and verifies it
Most of the time this works. But because server actions and the subsequent navigation are separate requests, and because RSC prefetches can start before the Set-Cookie from the action has been committed by the browser, we occasionally saw middleware reading the old cookie (or no cookie at all) while the client thought it was logged in.
On logout it was worse: the client cleared the cookie optimistically, but a prefetch already in flight still carried the old cookie, so middleware happily let it through and streamed protected data into the RSC payload cache.
The patterns that fixed it:
- Always redirect from the server action itself after mutating the session cookie, don't rely on the client to navigate
- Set cookies with
httpOnly,secure,sameSite: 'lax', and a short-lived rotation window - For logout, invalidate server-side too (session ID revoked in a store), so a stale cookie can't authenticate even if it arrives late
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function login(formData: FormData) {
const token = await authenticate(formData)
;(await cookies()).set('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60,
})
redirect('/app')
}
4. Middleware is not a security boundary on its own
This is the mindset shift. Middleware is a routing hint. It's great for redirecting anonymous users to a login page and for rewriting URLs. It is not the place to make the authoritative call on whether a user can see a resource.
Why? Because middleware doesn't run for every path (matcher gaps), it can be bypassed by direct API calls if you forget to include them, and any bug in the matcher pattern is now an auth bug. We treat middleware as the UX layer of auth and always re-check on the server:
// app/app/dashboard/page.tsx
import { requireUser } from '@/lib/auth'
export default async function Dashboard() {
const user = await requireUser() // throws or redirects if missing
const data = await getDashboardData(user.id)
return <DashboardView data={data} />
}
requireUser verifies the session cookie and checks the session against your data source of truth. Yes, that's a duplicate check. Yes, it's worth it. The middleware handles the redirect nicely for the 99% path; the server-side check makes sure nothing leaks when middleware doesn't run or has a bug.
When to move auth off the edge entirely
We still default to edge middleware for auth on marketing-adjacent apps with light session logic. But we move to the Node runtime — or drop middleware auth entirely in favour of per-route server checks — when any of these show up:
- Session verification needs a database lookup (revocation, impersonation, role changes that must take effect immediately)
- You're using an auth provider SDK that isn't edge-compatible
- Your matcher is getting long enough that you can't reason about it in one sitting
- You need feature flags, A/B logic, or geo rules on top of auth in the same middleware
Switching a middleware file to Node is one line:
export const config = {
matcher: ['/app/:path*'],
runtime: 'nodejs',
}
You lose some of the edge latency win, but you get the full Node API surface and, in our experience, more predictable cold-start behaviour when the middleware does any real work.
A checklist we now run before shipping
- Middleware does cookie read + JWT verify only. No DB, no external HTTP.
- Matcher excludes
_next/static,_next/image, and any public API paths. - Every protected route or server action re-verifies the session server-side.
- Login and logout server actions call
redirect()after mutating cookies. - Logout invalidates the session server-side, not just client-side.
- Session cookies are
httpOnly,secure,sameSite: 'lax', with a sanemaxAge. - Edge runtime is a deliberate choice, not a default nobody questioned.
Where we'd start
If you're auditing an existing App Router app, open your middleware.ts, list every side effect it does, and time each one. Anything above a few milliseconds on a warm invocation is a red flag because it multiplies across prefetches. Then grep your protected routes for a server-side auth check — if you can't find one on every page under /app, you're relying on middleware alone, and that's the bug waiting to happen. Fix the server-side checks first, then tune the middleware. The order matters. If you'd rather have another set of eyes on it, that's the kind of review we do as part of our web development work.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
The useOptimistic Trap: When Optimistic UI Lies to Your Users
React 19's useOptimistic looks like a free win until a server action fails halfway through a batch and your UI cheerfully shows state that never existed. Here's how we got burned and how we now use it.
Route Handlers vs Server Actions for Mutations: When Each One Actually Wins
Server Actions look like the obvious choice for every mutation in the App Router. They aren't. Here's how we decide between actions and route handlers on real projects, with the failure modes we've hit.
Cache Tags in the App Router: The Invalidation Story That Finally Works
Cache tags in Next.js finally give us a real invalidation model, but they punish sloppy tagging with stale data and surprise revalidations. Here's how we tag, invalidate, and debug them in production.
