Next.js Middleware Authentication: Protect Routes in App Router

Learn how Next.js middleware works, how to configure the matcher, validate JWTs at the edge, and redirect unauthenticated users — with the CVE-2025-29927 bypass covered.

 min. read
March 26, 2026
Star us on GitHub and stay updated
Next.js 16 note: In Next.js 16, middleware.ts was renamed to proxy.ts and the exported function renamed from middleware to proxy. This article uses Next.js 12–15 syntax. If you are on Next.js 16+, replace middleware.ts with proxy.ts and export function middleware with export function proxy — everything else in this guide applies unchanged. Vercel provides a codemod: npx @next/codemod@canary middleware-to-proxy .

Why Middleware Is the Right Place to Protect Routes

Imagine your Next.js app as a hotel. Every room is a route. You could put a lock on each individual room door — but that means copying the same lock logic into every page component. A smarter approach is to use Next.js middleware — station a security guard at the hotel entrance who checks every guest's key card before they even reach the elevator.

That's exactly what middleware.ts does in Next.js. It runs on the server before any route is rendered, which means you can check whether a user is authenticated and redirect them — all in one central place, with zero duplication across your pages.

In this guide you'll learn how Next.js middleware works, how to configure the matcher to target only the routes you care about, how to validate session tokens, how to redirect unauthenticated users, and — critically — how to avoid the most common traps like infinite redirect loops and accidentally running middleware on static assets.

How Next.js Middleware Works

Next.js middleware lives in a single file named middleware.ts (or middleware.js) at the root of your project, at the same level as the app/ or pages/ directory. Every incoming HTTP request passes through this file before Next.js renders any page or calls any route handler.

The execution order looks like this:

  1. Browser makes a request to your Next.js app
  2. middleware.ts runs — it can inspect the request, set headers, rewrite the URL, or redirect
  3. If middleware calls NextResponse.next(), the request proceeds to your page component or route handler
  4. If middleware calls NextResponse.redirect(), the user is sent to a different URL immediately

Because middleware runs at the edge (close to the user, before your app server), it uses a lightweight runtime. This means you cannot use Node.js-only libraries like jsonwebtoken directly — instead you'll use the jose library, which is edge-compatible.

// middleware.ts — minimal example
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // Inspect the request, then decide what to do
  return NextResponse.next() // allow the request through
}

The Matcher Config — Targeting Only the Routes You Need

By default, middleware runs on every request — including requests for CSS files, images, and Next.js internals like _next/static. Running authentication logic on those is wasteful and can cause subtle bugs. The matcher config lets you tell Next.js exactly which paths should trigger your middleware.

Basic Matcher Patterns

// middleware.ts
export const config = {
  matcher: [
    // Protect everything under /dashboard and /settings
    '/dashboard/:path*',
    '/settings/:path*',
  ],
}

The :path* syntax means “zero or more path segments”. So /dashboard/:path* matches /dashboard, /dashboard/analytics, /dashboard/analytics/weekly, and so on.

Excluding Public Paths with a Negative Lookahead

A common pattern is to match everything except static assets and public paths. This is done with a regex negative lookahead:

// middleware.ts
export const config = {
  matcher: [
    /*
     * Match all request paths EXCEPT:
     * - _next/static (static files like JS, CSS)
     * - _next/image  (Next.js image optimization)
     * - favicon.ico, sitemap.xml, robots.txt
     * - files with extensions like .png, .jpg, .svg
     */
    '/((?!_next/static|_next/image|favicon\.ico|sitemap\.xml|robots\.txt|.*\.(?:png|jpg|jpeg|gif|svg|ico|webp)$).*)',
  ],
}

Think of this as the hotel guest list. Everyone gets checked — except the maintenance crew (static files) who have their own door around the back.

Matcher Reference

Pattern What it matches
/dashboard Exactly /dashboard
/dashboard/:path* /dashboard and all nested paths
/api/:path+ All paths under /api/ (at least one segment)
/((?!login|register).*) Everything except /login and /register

Validating Sessions in Middleware

The most common approach is to store the user's session token in an httpOnly cookie after they log in, then read and verify that cookie in middleware on every protected request.

Reading a Cookie in Middleware

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // Read the session token from the cookie jar
  const token = request.cookies.get('session-token')?.value

  if (!token) {
    // No token — redirect to login
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

Verifying a JWT in Middleware

Checking that a cookie exists is not enough — a user could set a fake cookie. You need to cryptographically verify the JWT. Use the jose library, which works in the edge runtime. For a complete walkthrough of JWT setup and httpOnly cookie patterns, see our Next.js JWT authentication guide.

npm install jose
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET)

async function verifyToken(token: string): Promise<boolean> {
  try {
    await jwtVerify(token, JWT_SECRET)
    return true
  } catch {
    // Token is expired, tampered with, or otherwise invalid
    return false
  }
}

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session-token')?.value

  if (!token || !(await verifyToken(token))) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*'],
}

Redirecting Unauthenticated Users — A Complete Working Example

Below is a full middleware.ts that handles the three most common scenarios:

  1. Unauthenticated user tries to access a protected route → redirect to /login
  2. Authenticated user tries to access /login or /register → redirect to /dashboard
  3. All other requests → pass through unchanged
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET)

// Routes that require the user to be logged in
const PROTECTED_ROUTES = ['/dashboard', '/settings', '/profile']

// Routes that should redirect logged-in users away (e.g., to /dashboard)
const AUTH_ROUTES = ['/login', '/register']

async function getAuthenticatedUser(request: NextRequest): Promise<boolean> {
  const token = request.cookies.get('session-token')?.value
  if (!token) return false

  try {
    await jwtVerify(token, JWT_SECRET)
    return true
  } catch {
    return false
  }
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const isAuthenticated = await getAuthenticatedUser(request)

  // Check if the current path starts with any protected route
  const isProtectedRoute = PROTECTED_ROUTES.some((route) =>
    pathname.startsWith(route)
  )

  // Check if the current path is an auth route (login/register)
  const isAuthRoute = AUTH_ROUTES.some((route) => pathname.startsWith(route))

  if (isProtectedRoute && !isAuthenticated) {
    // Save where the user was trying to go, so we can redirect after login
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  if (isAuthRoute && isAuthenticated) {
    // Already logged in — no need to show login page
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon\.ico|sitemap\.xml|robots\.txt|.*\.(?:png|jpg|jpeg|gif|svg|ico|webp)$).*)',
  ],
}

Notice the callbackUrl query parameter — this lets your login page redirect the user back to the page they originally wanted after a successful login, which is a much smoother experience.

Using Authgear for Next.js Authentication

If you're using Authgear as your authentication provider, the @authgear/nextjs package handles token verification, session refresh, and auth state management without you having to wire it up manually.

npm install @authgear/nextjs

Once installed, you protect routes in middleware the same way as above — using Authgear's session cookie and the standard JWT verification pattern. The package also provides server-side helpers like currentUser() that you can use in Server Components and Route Handlers to fetch the authenticated user without re-verifying the token yourself.

One of the trickiest parts of rolling your own auth is token refresh — when the user's access token is close to expiring, you need to silently exchange it for a new one using the refresh token so users stay logged in without being interrupted. Authgear handles this transparently.

For full setup instructions including environment variables and the login page implementation, see the Authgear Next.js quickstart guide.

Common Pitfalls

Pitfall 1: The Infinite Redirect Loop

Warning: Infinite redirect loop

If your middleware redirects unauthenticated users to /login, but /login itself is matched by your middleware, the redirect will loop forever. The browser will show an “ERR_TOO_MANY_REDIRECTS” error.

The fix: Make sure your login and register pages are either excluded from the matcher pattern, or handled with an explicit early return in your middleware logic (as shown in the complete example above, where isAuthRoute paths pass through when the user is not authenticated).

// BAD: /login is caught by this matcher, causing a loop
export const config = {
  matcher: ['/:path*'],
}

// GOOD: /login and /register are excluded from the matcher
export const config = {
  matcher: ['/((?!login|register|_next/static|_next/image|favicon\.ico).*)',],
}

Pitfall 2: Running Middleware on Static Assets

If your matcher is too broad (e.g., /:path*), middleware runs on every request for every image, font, and CSS file. This adds latency to every asset load and can cause visual bugs if your middleware accidentally returns a redirect for a CSS file. Always exclude _next/static, _next/image, and common file extensions from your matcher.

Pitfall 3: Middleware Is Not a Complete Security Boundary

In March 2025, a critical vulnerability (CVE-2025-29927, CVSS 9.1) was disclosed in which middleware could be bypassed entirely by sending a crafted x-middleware-subrequest header. This affected self-hosted Next.js deployments running next start — Vercel and Netlify deployments were not affected. The vulnerability was patched in versions 12.3.5, 13.5.9, 14.2.25, and 15.2.3. Always run a patched version of Next.js.

More importantly, treat middleware as a first line of defense — a UX convenience that prevents most unauthorized access — but always re-verify authentication in your Server Components and API route handlers before exposing sensitive data. Defense in depth is the right approach. Read more about how JWTs work to understand why token verification at the data layer matters.

Pitfall 4: Using Node.js-Only Libraries

Middleware runs in the Edge Runtime by default, which does not support many Node.js APIs. If you try to use jsonwebtoken, crypto (the Node.js module), or database clients like Prisma directly in middleware, you'll get a runtime error. Use edge-compatible alternatives:

Task Node.js-only (avoid in middleware) Edge-compatible (use instead)
JWT verification jsonwebtoken jose
Hashing / crypto crypto (Node module) Web Crypto API (crypto.subtle)
Database queries Prisma, Mongoose Move queries to Server Components or Route Handlers

As of Next.js 15.2+, you can opt middleware into the Node.js runtime by adding runtime: 'nodejs' to your config export (stable since Next.js 15.5). This removes the Edge Runtime limitation, but middleware will no longer run at the CDN edge layer — it runs on your app server instead.

// middleware.ts — opt into Node.js runtime (Next.js 15.2+, stable in 15.5)
export const config = {
  runtime: 'nodejs', // enables full Node.js APIs, disables CDN-edge execution
  matcher: ['/dashboard/:path*'],
}

FAQ

Can I use middleware to protect API routes as well as pages?

Yes. Your matcher can include /api/:path* alongside page routes. In that case, returning NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) is better than a redirect, since API clients typically expect JSON error responses rather than HTML redirect pages.

What's the difference between using middleware vs. checking auth inside each page component?

Middleware runs before the page renders and can redirect without rendering any React at all, making it faster and more efficient. Checking auth inside each page component is slower (the page starts rendering before you can redirect) and requires you to copy the same logic into every protected page. Use middleware as your first guard, but also check auth in Server Components for sensitive data fetches — never rely solely on middleware for security.

Does middleware run during client-side navigation in Next.js?

In the App Router, client-side navigations fetch React Server Component (RSC) payloads from the server. Middleware does run for these RSC requests, so your auth checks apply to both initial page loads and subsequent navigations. One thing to know: Next.js strips internal RSC headers (like next-router-prefetch) from the request object inside middleware to prevent you from accidentally treating RSC fetches differently from HTML requests — and you should not need to. Your auth logic should treat them the same way.

How do I pass authenticated user data from middleware to my page components?

Middleware cannot directly pass data to React components. The correct pattern is to forward information via request headers using NextResponse.next({ request: { headers: ... } }), which your Server Components can then read using the headers() function from next/headers:

// In middleware.ts — forward the user ID via a request header
export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session-token')?.value
  if (!token) return NextResponse.redirect(new URL('/login', request.url))

  const { payload } = await jwtVerify(token, JWT_SECRET)

  // Clone and mutate the request headers, then pass them forward
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-user-id', payload.sub as string)

  return NextResponse.next({
    request: { headers: requestHeaders },
  })
}
// In your Server Component — read the forwarded header
import { headers } from 'next/headers'

export default async function DashboardPage() {
  const headersList = await headers()
  const userId = headersList.get('x-user-id')
  // Use userId to fetch user-specific data
}

For a more complete approach to session management and token handling, see the authentication solutions guide or explore how JWTs carry user identity so you understand what data is available in the token payload.

To skip the boilerplate entirely and add production-ready authentication to your Next.js app in minutes, try Authgear for free — it handles token issuance, refresh, session management, and MFA out of the box.

Preferences

Privacy is important to us, so you have the option of disabling certain types of storage that may not be necessary for the basic functioning of the website. Blocking categories may impact your experience on the website.

Accept all cookies

These items are required to enable basic website functionality.

Always active

These items are used to deliver advertising that is more relevant to you and your interests.

These items allow the website to remember choices you make (such as your user name, language, or the region you are in) and provide enhanced, more personal features.

These items help the website operator understand how its website performs, how visitors interact with the site, and whether there may be technical issues.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.