Skip to content
·6 min read

Next.js Middleware for Auth, The Patterns That Actually Work

Next.js middleware is the worst-documented feature in the framework. Edge runtime limits, JWT verification gotchas, redirect loops. Here's the setup I ship.

Next.jsAuthenticationMiddlewareWeb Dev

Next.js middleware is the worst-documented feature in the framework. The official docs show you a hello-world example, ship it, and discover in production that JWT verification fails, your users hit redirect loops, and half your database library doesn't work.

I've shipped middleware-based auth on three production apps. Here's the setup I use, after all the bugs.

The mental model

Middleware runs on every request before the route handler. It runs on the Edge Runtime by default, which is a restricted sandbox, no Node built-ins like crypto or fs. It's fast (cold start in milliseconds) but limiting.

// middleware.ts (in the root of your project, next to app/)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  // Runs on every matched request
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
}

The matcher is critical. Without it, middleware runs on every request, including images, CSS, and API routes.

The auth flow

The pattern: check for a session cookie, verify the JWT, redirect to /login if invalid.

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
 
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password']
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
 
  // Skip public paths
  if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
    return NextResponse.next()
  }
 
  // Read the session cookie
  const token = request.cookies.get('session')?.value
  if (!token) {
    return redirectToLogin(request)
  }
 
  try {
    // Verify the JWT
    const { payload } = await jwtVerify(token, secret)
    // Optionally: check payload.exp, payload.role, etc.
    return NextResponse.next()
  } catch {
    // Token is invalid or expired
    return redirectToLogin(request)
  }
}
 
function redirectToLogin(request: NextRequest) {
  const loginUrl = new URL('/login', request.url)
  loginUrl.searchParams.set('redirect', request.nextUrl.pathname)
  return NextResponse.redirect(loginUrl)
}
 
export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico|login|register|forgot-password).*)',
  ],
}

Three things to notice:

  1. jose instead of jsonwebtoken. jose works on Edge. jsonwebtoken doesn't.
  2. The matcher excludes static assets and public pages. Without this, middleware runs on every image and creates a redirect loop on /login.
  3. We store the user info in the JWT. We can't hit the database in middleware.

Why jose and not jsonwebtoken

jsonwebtoken is the most popular JWT library for Node. It also doesn't work in middleware.

The reason: middleware runs on the Edge Runtime, which doesn't have Node's crypto module. jsonwebtoken requires it. You'll see errors like Module not found: Can't resolve 'crypto'.

jose is a pure-JS JWT library that works on Edge. It's the officially recommended library.

import { jwtVerify, SignJWT } from 'jose'
 
// Verify in middleware
const { payload } = await jwtVerify(token, secret)
 
// Sign in your login route
const token = await new SignJWT({ userId: '123', role: 'admin' })
  .setProtectedHeader({ alg: 'HS256' })
  .setExpirationTime('7d')
  .sign(secret)

The redirect loop trap

This is the bug everyone hits:

  1. User visits /dashboard without a session
  2. Middleware redirects to /login
  3. Middleware runs again on /login
  4. Middleware redirects to /login (because no session)
  5. Browser: ERR_TOO_MANY_REDIRECTS

The fix is the matcher regex. Two options:

Option 1: Exclude public paths in the matcher (preferred)

export const config = {
  matcher: [
    // Match everything EXCEPT these paths
    '/((?!api|_next/static|_next/image|favicon.ico|login|register).*)',
  ],
}

Option 2: Check inside middleware

const PUBLIC_PATHS = ['/login', '/register', '/forgot-password']
 
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
  return NextResponse.next()
}

Both work. Option 1 is faster because middleware never runs at all on public paths.

Don't hit the database from middleware

Middleware runs on every page load. If you query the database on every request, you add 50-200ms to every page.

Instead, put everything you need in the JWT:

// Login route: sign a JWT with the role
const token = await new SignJWT({
  userId: user.id,
  role: user.role,
  organizationId: user.organizationId,
})
  .setProtectedHeader({ alg: 'HS256' })
  .setExpirationTime('7d')
  .sign(secret)

When the user's role changes (admin demoted to user), you have to reissue the JWT. The pattern: store a tokenVersion on the user, include it in the JWT, and bump it on role changes. Middleware checks if the token's version matches the latest version in a fast Edge-compatible store (Upstash Redis, for example).

For most apps, just expire tokens frequently (24 hours) and accept a small window of stale permissions.

The matcher config I ship

export const config = {
  matcher: [
    // Run on all paths EXCEPT:
    // - API routes (handled separately)
    // - Next internals (_next/static, _next/image)
    // - Static files in /public (by extension)
    // - Public auth pages
    '/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|login|register|forgot-password|reset-password).*)',
  ],
}

This runs middleware on real pages, skips everything that doesn't need auth.

When middleware isn't the right tool

Middleware is good for:

  • Auth checks (does the user have a valid session?)
  • A/B testing (which variant to show?)
  • Geolocation redirects
  • Feature flag checks (cached in a cookie)

Middleware is bad for:

  • Database queries (latency, Edge limits)
  • Complex authorization logic (do it in Server Components or Server Actions)
  • Session refresh (do it in a route handler, not middleware)
  • Anything that requires Node built-ins

If your middleware needs the database, it's probably the wrong layer. Move the check into a Server Component or a layout.

The patterns I avoid

Avoid: export const config = { matcher: ['/'] }, runs middleware on every route, including static files. Always scope the matcher.

Avoid: await db.user.findUnique(...) in middleware, too slow, often doesn't work on Edge. Put the data in the JWT.

Avoid: setting cookies in middleware for auth state, use a route handler to set the cookie on login, then middleware only reads.

Avoid: using middleware for everything, it's an auth and routing layer. Not a place for business logic.

Next.js middleware is an auth layer, not a database layer. Verify the JWT, redirect, and get out. Everything else belongs in a Server Component or a route handler.

Want auth done right in Next.js?

I set up Next.js middleware auth that doesn't loop, doesn't break the Edge Runtime, and doesn't ship your secrets to the browser. Let's talk.