All articles
Web DevelopmentAugust 20, 2026 6 min read

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:

  1. User submits login form via a server action
  2. Server action calls cookies().set('session', newToken, ...)
  3. Action returns, client navigates to /app/dashboard
  4. 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 sane maxAge.
  • 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.

#Next.js#App Router#Edge Runtime#Authentication#Performance

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project