Next.js Protected Routes: Middleware, JWT, and Server-Side Authorization

Vladislav Shkutovich Full-Stack Engineer
27 min

TL;DR

  • Protected routes in Next.js require two layers.
  • proxy.ts (or middleware.ts on Next.js 15) performs a fast session check and redirects anonymous users.
  • Authorization belongs next to the data, not in middleware.
  • JWTs must be verified with signature validation, not decoded.
  • Server Components, Route Handlers, and Server Actions must all re-check authorization.
  • The same architecture applies whether you’re using custom JWTs, Auth.js, Clerk, or Supabase Auth.

Version note: This article uses proxy.ts, introduced in Next.js 16. On Next.js 15 and earlier, use middleware.ts. The security architecture is the same: request interception is an early session check, while authorization belongs in the server-side data layer.

Next.js Authentication: Middleware vs. Real Route Protection

Search for Next.js protected routes and you’ll find dozens of tutorials that recommend the same pattern:

  1. Create middleware
  2. Read a session cookie
  3. Verify a JWT
  4. Redirect if authentication fails

That pattern is useful, but it is incomplete. Protected routes in Next.js are not really about pages. They are about data. Modern App Router applications expose data through:

  • Server Components
  • Route Handlers
  • Server Actions
  • APIs

As a result, Next.js middleware protected routes should be treated as a user-experience feature, not a security boundary. This article explains the architecture we use for Next.js authentication, why middleware alone is insufficient, and how to build protected routes that remain secure even when middleware is bypassed, misconfigured, or skipped.

What Are Protected Routes in Next.js?

The phrase “protected route” can mean three different things.

Hiding UI

A Client Component checks for a session and shows a login screen instead of content. This is cosmetic protection. The underlying data may still be accessible.

Blocking Navigation

A middleware or proxy.ts check intercepts requests before rendering. This improves user experience because signed-out users are redirected immediately.

Protecting Data

Authorization happens next to the database query, Route Handler, or Server Action. This is the only definition that survives direct requests to your endpoints.

For App Router applications, the third definition is the one that matters.

Next.js Protected Routes: The Two-Layer Security Model

Every protected route should answer the same question twice:

  1. Is this user likely authenticated?
  2. Is this user actually allowed to access this data?

Layer 1: Middleware or proxy.ts

The first check happens before rendering.

Responsibilities:

  • Read session cookie
  • Verify JWT signature
  • Verify expiration
  • Redirect anonymous users

Benefits:

  • Fast
  • Cheap
  • Excellent user experience

Limitations:

  • Has no database access
  • Cannot detect revocations
  • Cannot enforce business rules
  • Should not be treated as authorization

Layer 2: Data Access Layer

The second check happens where data is retrieved.

Responsibilities:

  • Verify session again
  • Load current user
  • Apply role checks
  • Apply ownership checks
  • Return safe DTOs

This is where authorization belongs.

Why Middleware Alone Can’t Protect Next.js Routes

A protected route implemented only in middleware is not truly protected. Middleware exists in front of the application. Authorization needs to live inside the application.

The CVE-2025-29927 Example

CVE-2025-29927 is a useful example of why middleware should not be the application’s only authorization boundary. The broader principle is simple: protection implemented only at the request boundary should never be the sole control protecting sensitive data.

The Security Principle

Use middleware to decide: Should this navigation continue?

Use the Data Access Layer to decide: Can this user access this data?

How to Protect Routes in Next.js 16 with proxy.ts

Next.js 16 renamed middleware to proxy.ts.

Basic Flow

Request

↓

proxy.ts

↓

JWT verification

↓

Redirect if unauthenticated

↓

Page / Route Handler / Server Action

↓

Data Access Layer

↓

Authorization

↓

Data returned

What proxy.ts Should Do

Keep middleware lightweight:

  • Read cookie
  • Verify token signature
  • Verify expiration
  • Refresh session if required
  • Redirect anonymous users

What proxy.ts Should Not Do

Avoid:

  • Database queries
  • Permission checks
  • Role lookups
  • Ownership validation

Keep these checks in the server-side authorization layer.

Scoping Protected Routes with config.matcher

A matcher determines which requests reach proxy.ts. It controls where proxy.ts runs, but it does not define your application’s complete authentication or authorization policy.

Use the matcher to keep Proxy focused on routes that benefit from an early session check:

  • Include protected application routes
  • Exclude static assets
  • Exclude image optimization routes
  • Exclude public pages such as login and signup
  • Exclude authentication endpoints when they do not require the protected-route check

The important distinction is that a route excluded from the matcher is not automatically authorized. Sensitive data and operations must still enforce authentication and authorization at the Server Component, Route Handler, Server Action, or Data Access Layer that accesses them.

matcher controls where Proxy runs; it does not define who is allowed to access your data.

JWT Verification for Next.js Authentication

A common mistake in NextJS auth implementations is decoding a JWT instead of verifying it.

Bad

TypeScript

decodeJwt(token)

Show more lines

This only decodes Base64 data.

Decoding only reads the token’s payload; it does not verify its signature.

Good

TypeScript

jwtVerify(token, secretKey, {

algorithms: ["HS256"],

issuer: SESSION_ISSUER,

audience: SESSION_AUDIENCE,

})

Show more lines

This validates:

  • Signature
  • Expiry
  • Issuer
  • Audience
  • Required claims

Only verified tokens should be treated as authenticated sessions.

JWT vs Opaque Sessions

A JWT is useful when:

  • Multiple services consume the token
  • Middleware needs authentication without DB access
  • External identity providers issue tokens

An opaque session ID is often simpler when:

  • One application exists
  • One database exists
  • Immediate revocation is important

Building a Data Access Layer for Next.js Auth

Sensitive data access and authorization should happen in a trusted server-side layer close to the data.

Responsibilities

The DAL should:

  • Verify sessions
  • Load users
  • Apply authorization rules
  • Return safe DTOs
  • Hide sensitive fields

Example Pattern

const session = await getSession()

if (!session) {

redirect("/login")

}

Why Sessions Must Be Rechecked

A JWT might still be valid while:

  • The account has been deleted
  • The user was disabled
  • The role changed
  • Access was revoked

Those checks require current authoritative server-side state.

Route Handlers, Server Actions, and Server Components

Modern Next.js applications expose multiple execution paths. Each must perform authorization.

Server Components

Pages should consult the Data Access Layer before rendering sensitive data.

Server Actions

Server Actions are public endpoints. Treat them the same way you would treat REST APIs. A Server Action is callable through an HTTP request, so don’t assume that because it is defined in a server-only file it is automatically authorized.

Validate:

  • Authentication
  • Authorization
  • Input

on every request.

"use server";

export async function deleteProject(projectId: string) {

const user = await requireUser();

const project = await getProject(projectId);

if (project.ownerId !== user.id) {

`throw new Error("Forbidden");`

}

await deleteProjectFromDatabase(projectId);

}

Route Handlers

Route Handlers should never trust page-level protection. They must independently verify authorization.

Role-Based Access Control for Next.js Protected Routes

Role checks belong in the database layer.

Do Not Trust JWT Role Claims

A token issued before a demotion still contains the old role.

For example:

JWT: admin

Database: member

For permissions that can change during a session, authorization should use the current authoritative server-side state rather than trusting potentially stale role claims in a JWT.

TypeScript

requireRole("admin")

Show more lines

The helper:

  1. Loads current user
  2. Reads current role
  3. Applies rules
  4. Allows or denies access

Protected Routes with Auth.js and Supabase

The architecture remains the same regardless of provider.

Auth.js Protected Routes

Auth.js is often used to implement protected routes in Next.js, and its middleware can simplify the initial authentication check.

However:

  • It improves navigation by handling the initial authentication check.
  • Authorization still belongs in your Data Access Layer (DAL).
  • Server Actions and Route Handlers still require their own authorization checks.

The authentication provider does not change this security model.

Supabase Protected Routes in Next.js

When implementing Supabase protected routes in Next.js:

Middleware frequently:

  • Refreshes sessions
  • Attaches authentication context

Authorization should still happen in:

  • Server Components
  • Route Handlers
  • Server Actions
  • Database policies

Authentication identifies users. Authorization grants access.

Common Mistakes in Next.js Authentication

1. Middleware As The Only Protection

The most common mistake.

2. Decoding Instead of Verifying JWTs

Decoded payloads prove nothing.

3. Trusting Layouts

Layouts do not always rerun. Do not treat them as security boundaries.

4. Treating Server Actions As Private

They are public HTTP endpoints.

5. Storing Protected Files Under public/

Files inside /public bypass your authorization logic.

6. Trusting Role Claims

When permissions can change during a session, use current server-side role data rather than potentially stale JWT claims.

7. Infinite Sliding Sessions

Always enforce a maximum session lifetime.

8. Database Queries in Middleware

Middleware runs on every matched request.

Should You Build Next.js Authentication Yourself?

For most applications, an established authentication library reduces the amount of security-sensitive code you need to maintain. Libraries provide:

  • Password hashing
  • Session rotation
  • OAuth providers
  • Magic links
  • Email verification
  • Security hardening

A custom implementation is reasonable when you only need:

  • A single signed cookie
  • One purpose
  • One verification route

Anything larger usually benefits from an established authentication solution.

Practical implementation note

In production Next.js + Payload projects, we’ve used both CMS-managed authentication and jose for verifying tokens from OIDC flows. The implementation choice depends on whether authentication is handled by the CMS, an identity provider, or the application itself.

How We Verified the Architecture

We run the same checks in every Next.js security audit. Testing included:

  • Tampered tokens
  • Revoked users
  • Role changes
  • Open redirect attempts
  • Download endpoints
  • Cross-site requests
  • Session refresh behavior
  • Middleware migration scenarios

The critical test:

  1. Keep a valid cookie
  2. Delete the account
  3. Access a protected route

Result:

  • Middleware permits navigation
  • The Data Access Layer denies access
  • User is redirected to login

This demonstrates why authorization belongs next to the data.

Next.js Protected Routes: Secure the Data, Not Just the Page

In Next.js, protected routes are ultimately about controlling access to data, not pages. Middleware and proxy.ts provide a fast authentication check that verifies sessions, redirects anonymous users, and keeps requests cheap.

The Data Access Layer understands the current state of your application: users, roles, ownership rules, revocations, and business logic. It is the security boundary that decides what data leaves the server. Whether you use custom JWT authentication, Auth.js, Supabase Auth, or another Next.js authentication solution, the architecture stays the same:

  • Middleware improves navigation
  • The Data Access Layer protects data
  • You need both, but only the Data Access Layer has the final say

Not sure where your app stands?

Most authentication issues we find in Next.js projects are not exotic exploits. They are the gaps covered in this article: authorization that exists only in middleware, JWTs that are decoded but never verified, Server Actions assumed to be private, and role claims trusted over current server-side data.

Our Next.js security audit applies the same checks used to validate this architecture, including tampered tokens, revoked users, role changes, and every entry point that bypasses middleware.

If you need a second set of eyes on those risks, you can book a Next.js audit.

Next.js Protected Routes FAQ

A function that runs in front of the application on every request the matcher selects, before routing and rendering, and can redirect, rewrite or pass the request through. On Next.js 16 the file is proxy.ts and the docs call the feature Proxy; middleware.ts still works and prints a deprecation warning.

At the project root or inside src/, next to app/, named proxy.ts on Next.js 16 or middleware.ts on 15. If both exist, next build stops with "Both middleware file ... and proxy file ... are detected".

Read them from the request, request.cookies.get("session")?.value, and set them on the response you return, response.cookies.set(...). In Server Components, Server Actions and Route Handlers, use cookies() from next/headers, which is async in Next.js 15 and 16.

In a cookie set by the server with HttpOnly, Secure and SameSite=Lax. Never in localStorage: any script on the page can read it, and middleware never sees it. If a route delivers files to an <a download> anchor, answer a refusal with a 204 and no body, so that no browser has anything to save as the file.

No. Next.js loads one proxy.ts or one middleware.ts per project. Compose inside it: branch on request.nextUrl.pathname, or call small functions in order and return the first response that is not NextResponse.next().

No. It sees a token and never the store, so it cannot know that an account was deleted or a role revoked, and on unpatched self-hosted versions CVE-2025-29927 let a request header skip it entirely. Verify again in a Data Access Layer that every Server Component, Server Action and Route Handler calls before touching data.