Skip to content
advanced Phase 10 · Authentication and Sessions

Protecting Routes in Middleware

Redirect unauthenticated visitors before the route runs.

45m
0 problems
Topic Progress 0%

Redirecting Anonymous Visitors Before the Route Renders

Open /dashboard signed out in a default Next.js app and you get a 200 response containing a page that immediately redirects to /login. The protected page's layout runs, its queries fire, and the browser flashes content before the redirect lands — a wasted round trip for the user and a public endpoint for anything the page fetched. Middleware removes that gap: middleware runs before a request reaches the route, so it can redirect unauthenticated users at the edge of your server, before rendering, before caching, before your data layer.

A file called middleware.ts at the project root (next to app/, or inside src/ when you use it) intercepts matched requests. Auth.js v5 lets you hand it your auth wrapper directly, and the request arrives already annotated with req.auth:

// middleware.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const { nextUrl } = req
  const isProtected =
    nextUrl.pathname.startsWith("/dashboard") ||
    nextUrl.pathname.startsWith("/settings")

  if (isProtected && !req.auth) {
    const login = new URL("/login", nextUrl)
    // remember where the user was headed, stripped to path + query
    login.searchParams.set("callbackUrl", nextUrl.pathname + nextUrl.search)
    return NextResponse.redirect(login)
  }
  return NextResponse.next()
})

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

If you only need the gate and no custom logic, the shorthand is one line: export { auth as middleware } from "@/auth". Both forms do the same thing — verify the session cookie (for a JWT strategy that means signature and exp, using jose, which runs in the edge runtime where Node's crypto does not).

Three properties make this the right layer for the redirect decision. It fails fast: an anonymous request to a 40-query page costs one cookie check instead of forty queries. It is a single choke point, so a new protected path is one matcher entry rather than a guard copied into ten files. And the response is a 307 with a Location header, so no protected markup is ever transmitted to a browser that should not see it.

Two details decide whether it works in practice. First, the round trip back: after sign-in you want to continue to the page the user originally asked for, so carry it in callbackUrl — but validate it. An open redirect turns your login page into a phishing springboard (?callbackUrl=https://evil.example), so only honor same-origin paths, or let Auth.js's own signIn handle validation against AUTH_TRUST_HOST. Second, preserve the method: NextResponse.redirect() issues a new request, which is correct for a GET that hit a wall but wrong if you were tempted to redirect a POST mid-action — for mutations, reject with a status instead of moving the browser.

Match the response to the audience. A browser navigating to a protected page wants a 307 to /login; a fetch() calling a protected JSON endpoint wants 401, because following a Location header lands the client on an HTML login page and the caller parses a string where it expected data. Split the two inside the same middleware — nextUrl.pathname.startsWith("/api") ? 401 : redirect — so each client gets an answer it can act on.

Middleware also has a ceiling you must accept up front. It runs on the edge runtime: no Node-only drivers, no direct database socket, no filesystem. So it can verify a signed cookie, but it cannot ask the database whether this user's role changed thirty seconds ago. That boundary is why the middleware gate is a UX optimization and the page's own auth() check remains the security boundary — a theme Role-Based Access Control returns to. The identity it reads comes from the cookie issued in Session Reads in Server Components.

Matcher Config: What You Pay For, and the One Path You Must Never Redirect

config.matcher is the difference between a middleware that costs microseconds on a handful of routes and one that runs on every static asset request your site serves. Matching only protected paths keeps public pages free of unnecessary checks, and it keeps your latency budget intact.

There are two styles, and they are not equivalent:

// Style A — allow-list: only these paths enter the middleware at all
export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"],
}

// Style B — block-list: everything except excluded prefixes
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
Style Runs on Risk
Allow-list of protected paths Just the protected routes A new route is unprotected until added
Negative lookahead over everything All HTML routes, plus anything you forgot to exclude Middleware latency on public pages, self-inflicted redirects
No config at all Every matched request, including assets Same, with no upside

The matcher is evaluated against the pathname only — the query string is ignored, so ?tab=settings cannot be part of a pattern. Syntax is path-to-regexp, not a JavaScript RegExp literal: paths must start with /, :path* means "this segment and everything below," and a negative lookahead must be written inside a single-segment wrapper such as /((?!api|_next).*). The config object must also be statically analyzable; a computed string means Next.js cannot build the matcher and the file is rejected at build time.

Now the failure that earns its place in interviews. Style B above excludes /api, and that exclusion is exactly what saves you — because if your redirect logic does match the OAuth callback, you break sign-in in a way that looks like a provider bug:

User approves at GitHub
  → 302 to /api/auth/callback/github?code=1f9d43&state=Xk9pQ2r7tL
  → YOUR middleware sees no session, redirects to /login
  → code and state are dropped with the original URL
  → Auth.js reports an OAuthCallback / state mismatch error

The authorization code is single-use and expires in minutes, so there is no retry. The callback path must be excluded from any redirecting matcher, alongside the library's own routes: exclude /api/auth/* explicitly if your pattern keeps /api in scope. The same reasoning extends to static files — running your session check for /_next/static/chunks/... costs edge invocations and adds nothing, because an unauthenticated user is allowed to download JavaScript.

A practical allow-list for a medium app looks like this, and it is the shape to reach for by default:

export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/admin/:path*",
    // deliberately absent: "/", "/pricing", "/login", "/api/auth/:path*",
  ],
}

Two matcher facts save time when debugging. Middleware runs before rewrites are resolved, and a rewrite does not trigger it again — so an internal rewrite from /app to /dashboard will not magically pick up a second pass through your guard; put the check where the incoming path is. And the config is static: no process.env.FLAG, no computed arrays. To verify coverage, log the pathname at the top of the middleware during a manual walk of the app; a route that never prints is a route your guard has never seen.

Audit it the honest way: list every route that renders user-specific data, then check each appears in the matcher. A route missing from an allow-list fails open — it renders for everyone — which is why the page-level auth() check from Verifying JWTs on the Server is still required, and why an allow-list typo is a security bug rather than a performance one.

Setting Cookies on the Response You Actually Return

Middleware has one more job beyond redirecting: refreshing or recording state on the way through. It is also where cookies silently disappear, because the rules for writing a cookie in middleware are not the rules for writing one in a Server Action.

You cannot use cookies() from next/headers here — that API belongs to the server component and route handler layer. Middleware reads from the request and writes to the response:

import { NextResponse, type NextRequest } from "next/server"

export function middleware(request: NextRequest) {
  const seen = request.cookies.get("onboardingSeen")?.value
  const response = seen
    ? NextResponse.next()
    : NextResponse.redirect(new URL("/onboarding", request.url))

  if (!seen) {
    // the cookie must ride on the redirect itself
    response.cookies.set("onboardingSeen", "1", {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",           // without this the default scope can be too narrow
      maxAge: 60 * 60 * 24 * 365,
    })
  }
  return response
}

The middleware response must set any cookies it wants to update on the client. Mutating request.cookies changes what downstream code reads, not what the browser stores — the browser only ever sees Set-Cookie headers on the response that comes back. The classic bug is building both responses:

const next = NextResponse.next()
next.cookies.set("flash", "welcome", { path: "/" })
return NextResponse.redirect(new URL("/login", request.url))  // Set-Cookie discarded

The redirect you returned carries no Set-Cookie, the browser follows Location, and your marker or session update is gone. Everything downstream then behaves as if middleware never ran — a symptom that sends people hunting through the page code where nothing is wrong. Attach the cookie to the object you return, whatever kind of response it is: next(), redirect(), or rewrite().

Redirects deserve one more caution: cookie loss on redirect also happens across two hops. If middleware sets a cookie and then the destination route immediately redirects again through a response built without those headers, the second hop overwrites the first. When a flow needs state across redirects — the returnTo path, a flash message, a CSRF token — set it once, on the final redirect response, with path: "/" and sameSite: "lax" so it survives the navigation and is not sent on cross-site POSTs.

One structural constraint shapes how you organize this: a Next.js project has exactly one middleware.ts, at the root or inside src/ — there is no per-directory middleware, and a file nested under app/dashboard/ is simply not picked up. Session handling, geo redirects, and A/B flags all share that single file, so keep it a thin dispatcher that delegates to helpers rather than a thousand-line maze; the ordering of those checks then becomes a review item in its own right.

Then the boundary: middleware is a gate, not an authorization layer. It cannot consult your roles table, it runs only for paths its matcher lists, and a self-hosted deployment with a custom server can skip it entirely. So the division of labor is fixed:

Layer Can do Cannot do
Middleware Verify signature/exp, redirect, set cookies Read the database, enforce fine-grained roles
Page / layout Full session + database role check, notFound() Prepend a redirect before render
Server action / route handler Re-check identity and authorization on every mutation Trust that middleware already ran

Keep the middleware gate for redirect latency and user experience; keep the in-route auth() check as the thing that actually denies access. Token verification inside the middleware — signature first, exp second — is the subject of Verifying JWTs on the Server, and the cookie it inspects was minted back in OAuth and Credentials Providers.

Quiz

1. Your middleware redirects unauthenticated visitors to /login and its matcher includes the OAuth callback. A user approves access at GitHub — what is lost, and what do they see?

Question 1 options

2. Which statement about config.matcher is correct?

Question 2 options

3. A middleware creates const next = NextResponse.next(), sets a cookie on it, then returns NextResponse.redirect(...). What does the browser receive?

Question 3 options

4. What can middleware NOT do when deciding whether a request passes?

Question 4 options

Flashcards

Question

Why put an auth check in middleware instead of in each protected page?

Answer

Middleware runs before the request reaches the route, so unauthenticated traffic is redirected before layouts render, caches are consulted, or queries run — one choke point, one matcher entry, and no protected markup ever sent to a browser that should not see it.

Question

What is the safest matcher shape for an app with protected areas under /dashboard, /settings, and /admin?

Answer

An allow-list: matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"]. It keeps public pages and every API route out of the middleware entirely, and it makes the protected surface auditable — the flip side is that a new protected path is unprotected until added to the list.

Question

Why must /api/auth/* be excluded from any middleware that redirects unauthenticated requests?

Answer

The OAuth callback arrives with no session. Redirecting it drops the single-use authorization code and state from the query string, and since the code expires within minutes the exchange can never be retried — sign-in fails with an OAuthCallback/state error that looks like a provider bug.

Question

How do you set a cookie from middleware, and where must it be attached?

Answer

Use request.cookies.get(...) to read and response.cookies.set(name, value, opts) to write — next/headers cookies() is unavailable here. The cookie must be set on the response object you return: attaching it to next() while returning a redirect means no Set-Cookie ever reaches the browser.

Question

Why is middleware not an authorization layer?

Answer

It runs on the edge runtime with no database driver, so it cannot look up current roles, and it only executes for paths its matcher lists — a typo fails open. It is a fast redirect for UX; the authoritative check still belongs in the page, server action, and route handler that do the work.

Revision Notes

Key Takeaways

  • 1. Middleware runs before the route, so redirecting anonymous traffic there prevents protected markup and queries from ever being produced
  • 2. A narrow allow-list matcher keeps public pages free of session checks and keeps latency off every static asset
  • 3. The OAuth callback must never be redirected: doing so drops the single-use code and state and breaks sign-in irrecoverably
  • 4. Cookies reach the browser only through the response you return — mutating a response you discard loses them silently
  • 5. Middleware is a UX gate on the edge runtime; the security decision still belongs in the server code that touches your data

Interview Tips

  • • "Protect /dashboard with middleware" — show auth((req) => ...), branch on req.auth, redirect with a same-origin callbackUrl, and mention NextResponse.next() as the pass-through; then immediately say where you would still re-check
  • • "Your matcher runs on every request — is that a problem?" — yes: latency on public pages and assets; answer with an allow-list and note the one path that must stay excluded, /api/auth/*, with the reason (code and state are dropped on redirect)
  • • "Is middleware enough to enforce authorization?" — no, and say why in one breath: edge runtime has no database, matcher typos fail open, and self-hosted setups can skip it; the page/action checks are the boundary
  • • "How does middleware update a session cookie?" — response.cookies.set on the returned response, with httpOnly/secure/sameSite/path spelled out; if you set it on a response you do not return, the browser never sees it

Cheat Sheet

Protecting Routes in Middleware

Shape of the file:

// middleware.ts (project root, next to app/ — or inside src/)
import { auth } from "@/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const protected_ = req.nextUrl.pathname.startsWith("/dashboard")
  if (protected_ && !req.auth) {
    const login = new URL("/login", req.nextUrl)
    login.searchParams.set("callbackUrl", req.nextUrl.pathname + req.nextUrl.search)
    return NextResponse.redirect(login)
  }
  return NextResponse.next()
})

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],  // allow-list: only guards
}

Shorthand when no logic is needed: export { auth as middleware } from "@/auth".

Matcher rules:

Rule Detail
Matches Pathname only — query string is ignored
Syntax path-to-regexp: must start with /, :path* = subtree
Must be static Computed values are rejected at build time
Never redirect /api/auth/* (drops code + state), static assets

Cookies: read request.cookies, write response.cookies — and set them on the response you actually return:

const res = NextResponse.redirect(new URL("/login", request.url))
res.cookies.set("returnTo", path, { httpOnly: true, secure: true, sameSite: "lax", path: "/" })
return res

Division of labor: middleware = fast redirect (edge, no DB) · page/layout = authoritative guard · action/handler = re-check on every mutation.

Continue Learning