Authentication in the Next.js App Router: patterns that hold up in production

Authentication in the Next.js App Router: patterns that hold up in production

App Router auth bugs almost never look like “login is broken.” They look like a dashboard that works perfectly when you click through the UI, then leaks a customer record when someone pastes the URL directly. Or a Server Action that updates a billing plan for any user who calls it, because the button that triggers it was hidden behind a role check in the UI and nothing else.

The pattern repeats: auth works on the happy path and fails silently at the edges. A client component redirects unauthenticated users, but the server already sent the protected data in the initial payload. A layout checks the session, and a nested route segment fetches data on its own without waiting for that check to mean anything.

This article shows you exactly where an auth check has to live in the App Router: in the proxy layer, in Server Components, in Route Handlers, and inside every Server Action, so protection is enforced at each layer instead of assumed to cascade down from a parent. Next.js authentication problems are mostly placement problems, and placement is something you can reason about precisely.

Written for Next.js 16 (released October 21, 2025). Next.js 16 renamed middleware.ts to proxy.ts, and the exported function is now proxy. Proxy always runs on the Node.js runtime, and you can’t configure that runtime. Teams that need the Edge runtime keep using middleware.ts, which is deprecated but still supported (Next.js 16 upgrade guide). Check that your auth library supports Node.js in proxy.ts. The Next.js authentication guide reflects the current API, and the code examples below use the current naming and runtime.

In this guide:

The production auth model: identity, sessions, and access

Three separate jobs get collapsed into one word, and that collapse is where most App Router auth designs go wrong. Splitting them apart makes every later decision easier.

Authentication, session management, and authorization serve different jobs

  1. Authentication proves who someone is: a password check, an OAuth callback, a magic link click. It happens once per login.
  2. Session management keeps that proof alive across requests. A signed cookie carries a session ID or a JWT, and the server reads it on every request.
  3. Authorization decides what that identity can do. Can this user read invoice 4821? Can they delete a teammate?

Authentication happens at one point in time. Authorization happens on every single data access. Treating a valid session as permission to see any data is the most common design error in App Router apps.

A request-level trust boundary prevents client-side-only exposure

Every request that returns data needs its own server-side check. The client is a rendering surface, never a gate.

Consider what a client-side guard does:

'use client'

// This does NOT protect anything.

export function Guard({ children, user }) {

 if (!user) redirect('/login')

 return children

}

By the time this runs, the server already rendered and streamed the payload. If a parent Server Component fetched invoice data and passed it down, that data is in the HTML and in the RSC payload. Hiding it in the browser changes nothing.

The OWASP Next.js security cheat sheet puts authorization close to the data source, rather than relying on routing or UI checks. That is the trust boundary: the function that reads the database. The same cheat sheet cites CVE-2025-29927 as the concrete reason not to rely on the proxy/middleware layer alone: affected self-hosted Next.js versions allowed an authorization bypass when a check lived only in that layer. Patch the framework (fixed in 12.3.5, 13.5.9, 14.2.25, and 15.2.3), but design as if a framework-level bypass is possible, because one already was.

Choose stateless or database-backed sessions based on revocation and scale needs

Stateless (JWT in cookie)Database-backed (session ID in cookie)
Read cost per requestVerify signature, no I/OOne database or cache lookup
Instant revocationNo, valid until expiryYes, delete the row
Works in Edge runtimeYesOnly via HTTP-based data layer
Size limitCookie limit, roughly 4 KBJust an ID

Pick stateless when you need low-latency checks and can tolerate a short expiry (5 to 15 minutes) plus refresh. Pick database-backed when you need to kill a session the moment an admin fires someone.

A common hybrid: a short-lived JWT for fast checks, plus a database session record consulted at the data layer for anything sensitive.

Where should you validate auth in an App Router application?

Every layer in the App Router can see something about the request, and only some layers can safely enforce a rule. This table is the core of the article; the other sections expand on each row.

LayerRuns whenCan it read the session?Safe to enforce access?Use it for
proxy.tsBefore the route resolvesCookie directly; always runs on the Node.js runtime in Next.js 16, though database calls here still add latency to every requestCoarse only. Optimistic redirectRedirect anonymous users away from /dashboard, rewrite by tenant
Server Component / layoutDuring render, on the serverYes, full Node.js runtime, direct database accessYes, for what it rendersGate page data and rendering
Client ComponentIn the browser, after the response is sentOnly what the server already handed itNo. Response already sentShow or hide UI, hydrate auth state
Route Handler (route.ts)On request to that URLYes, reads cookies and headers directlyYes, and requiredAuthenticate API requests before returning data
Server ActionOn POST to the action endpointYes, but inherits nothing from the pageYes, and requiredMutations, with its own check every time
Data access layerWhenever any of the above calls itYesYes, the last lineOwnership and role rules per query

Use the server component check to gate protected page data and rendering

A Server Component runs on the server before any HTML reaches the browser, so a failed check can stop rendering entirely.

// app/dashboard/page.tsx

import { redirect } from 'next/navigation'

import { auth } from '@/auth'

import { getInvoices } from '@/lib/data/invoices'

export default async function DashboardPage() {

 const session = await auth()

 if (!session?.user) redirect('/login')

 const invoices = await getInvoices(session.user.id)

 return <InvoiceList invoices={invoices} />

}

auth() reads and verifies the session cookie. redirect() throws, so nothing below it runs and no invoice data is fetched.

Use the proxy layer for early redirects and broad request filtering

proxy.ts runs before the route resolves, making it cheap and fast to turn away requests with no cookie at all. 

Treat it as an optimistic check: Next.js’s own guidance is explicit that Proxy should not be your only line of defense, partly because a `matcher` can be misconfigured to skip a route, and partly because CVE-2025-29927 already showed what happens when a framework bug lets requests bypass this layer entirely on affected versions.

Use route handlers to authenticate API requests before returning data

A Route Handler is a public HTTP endpoint. Anything reachable by fetch is reachable by curl.

// app/api/invoices/route.ts

import { NextResponse } from 'next/server'

import { auth } from '@/auth'

import { getInvoices } from '@/lib/data/invoices'

export async function GET() {

 const session = await auth()

 if (!session?.user) {

   return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

 }

 const invoices = await getInvoices(session.user.id)

 return NextResponse.json(invoices)

}

Use client components for auth state hydration, not as the security boundary

Client Components receive props the server chose to send. Use them to render a name, toggle a menu, or disable a button.

'use client'

export function UserMenu({ name, role }: { name: string; role: string }) {

 return (

   <nav>

     <span>{name}</span>

     {role === 'admin' && <a href="/admin">Admin</a>}

   </nav>

 )

}

The hidden admin link is a convenience. The /admin route still needs its own server-side check, because anyone can type the URL.

Make every server action perform its own authorization check

A Server Action compiles to a POST endpoint with a generated ID. Next.js does not attach the calling page’s session state to it. Anyone who finds the action ID can call it with any arguments they like.

'use server'

import { auth } from '@/auth'

import { db } from '@/lib/db'

export async function deleteInvoice(invoiceId: string) {

 const session = await auth()

 if (!session?.user) throw new Error('Unauthorized')

 // Ownership check, not just an identity check.

 const invoice = await db.invoice.findUnique({ where: { id: invoiceId } })

 if (invoice?.ownerId !== session.user.id) throw new Error('Forbidden')

 await db.invoice.delete({ where: { id: invoiceId } })

}

Server Functions are also where the most serious recent Next.js vulnerability landed. Our Next.js security guide covers the React Server Components RCE (CVE-2025-55182) and how to audit every endpoint that exposes a Server Function.

Route protection patterns that fail safely

A pattern fails safely when the default outcome of a missing or broken check is denial, never exposure. That principle drives the four patterns below.

Protect page groups without assuming a layout check covers every risk

A layout check protects what the layout renders. It does not protect a sibling route segment that fetches its own data.

Layouts also do not re-run on every client-side navigation within the same segment. If a session expires while a user browses, a cached layout can keep rendering while a child fetches fresh data with a dead session. Put the check where you read the data.

// lib/dal.ts

import 'server-only'

import { cache } from 'react'

import { redirect } from 'next/navigation'

import { auth } from '@/auth'

export const requireUser = cache(async () => {

 const session = await auth()

 if (!session?.user) redirect('/login')

 return session.user

})

cache() deduplicates the check within a single render pass, so calling requireUser() in a layout and in three child components costs one verification. The server-only import makes the build fail if this file is ever imported into a Client Component.

Redirect unauthenticated users without creating redirect loops

Redirect loops happen when the login page itself sits behind the redirect rule. Exclude the auth routes explicitly and preserve the destination.

// proxy.ts

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

const PUBLIC = ['/login', '/signup', '/api/auth']

export function proxy(request: NextRequest) {

 const { pathname } = request.nextUrl

 if (PUBLIC.some((p) => pathname.startsWith(p))) return NextResponse.next()

 const token = request.cookies.get('authjs.session-token')?.value

 if (!token) {

   const url = new URL('/login', request.url)

   url.searchParams.set('callbackUrl', pathname)

   return NextResponse.redirect(url)

 }

 return NextResponse.next()

}

export const config = {

 matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],

}

This checks cookie presence, nothing more. It is an optimistic redirect that saves a render, and it never replaces the server-side check.

Keep public pages out of that rule, too. If a route you want indexed sits behind the redirect, Googlebot gets a 307 to /login instead of your content. Our Next.js SEO guide covers how rendering and redirects affect indexing.

Enforce role and ownership rules close to the data access layer

Role checks scattered across pages drift out of sync. Put them in the query function so every caller inherits them.

// lib/data/invoices.ts

import 'server-only'

import { requireUser } from '@/lib/dal'

import { db } from '@/lib/db'

export async function getInvoice(id: string) {

 const user = await requireUser()

 const invoice = await db.invoice.findUnique({ where: { id } })

 if (!invoice) return null

 if (invoice.ownerId !== user.id && user.role !== 'admin') return null

 return invoice

}

Returning null for both “missing” and “not yours” avoids leaking whether an ID exists.

Return safe failures for APIs, forms, and background-triggered requests

Match the failure to the caller. A page redirects. A Route Handler returns 401 or 403 with no body detail. A Server Action returns a plain error object the form can render, without echoing internal IDs or stack traces.

'use server'

export async function updateProfile(prev: unknown, formData: FormData) {

 const session = await auth()

 if (!session?.user) return { error: 'Please sign in again.' }

 // ...validate and write

 return { ok: true }

}

Provider integration and session storage choices

Your provider choice sets who owns session verification, refresh, and the login UI. Both self-hosted and managed options work well in the App Router; the difference is where the operational work lands.

Better Auth for new projects, Auth.js for existing ones

For a self-hosted auth library, the choice is simpler than it was a year ago. In September 2025, Auth.js (formerly NextAuth.js) moved under the Better Auth team. That team now handles Auth.js security patches and urgent fixes, and recommends starting new projects on Better Auth.

Start new projects on Better Auth. It keeps users, sessions, and accounts in your database and adds features like two-factor authentication and passkeys through first-party plugins instead of custom code.

// lib/auth.ts

import { betterAuth } from 'better-auth'

import { nextCookies } from 'better-auth/next-js'

export const auth = betterAuth({

  // database: your adapter (Prisma, Drizzle, or a direct connection)

  emailAndPassword: { enabled: true },

  socialProviders: {

    github: {

      clientId: process.env.GITHUB_CLIENT_ID!,

      clientSecret: process.env.GITHUB_CLIENT_SECRET!,

    },

  },

  plugins: [nextCookies()], // keep nextCookies last

})

// app/api/auth/[...all]/route.ts

import { auth } from '@/lib/auth'

import { toNextJsHandler } from 'better-auth/next-js'

export const { GET, POST } = toNextJsHandler(auth)

In Server Components, Route Handlers, and Server Actions, read the session with auth.api.getSession({ headers: await headers() }). The examples earlier in this article call a generic auth() helper. With Better Auth, wrap that getSession call in your data access layer, and the patterns stay the same.

Add passkeys while you’re there. Better Auth ships passkey (WebAuthn) support as a first-party plugin. Install @better-auth/passkey, add passkey() to the server config and passkeyClient() to the auth client, then run the migration for its tables. Offering passkeys alongside your existing sign-in methods gives users a phishing-resistant login without a password to reset.

Keep Auth.js in existing apps. If your app already runs Auth.js v5 (next-auth@5), there’s no urgent reason to migrate. It still gives you one auth() helper for Server Components, Route Handlers, Server Actions, and proxy.ts, and it still receives security patches. It also stays a reasonable pick when you depend on a provider or SSO setup that Better Auth doesn’t support yet. Better Auth’s own advice is to start new projects on it “unless there are some very specific feature gaps,” so check your providers against its docs first. An Auth.js v5 setup looks like this:

// auth.ts

import NextAuth from 'next-auth'

import GitHub from 'next-auth/providers/github'

export const { handlers, auth, signIn, signOut } = NextAuth({

 providers: [GitHub],

 session: { strategy: 'jwt', maxAge: 60 * 60 * 24 * 7 },

 callbacks: {

   async jwt({ token, user }) {

     if (user) token.role = user.role

     return token

   },

   async session({ session, token }) {

     session.user.role = token.role as string

     return session

   },

 },

})

// app/api/auth/[...nextauth]/route.ts

export { GET, POST } from '@/auth'

Skip Lucia. Older tutorials still recommend it, but Lucia was deprecated in March 2025 and now serves as a learning resource rather than a maintained package.

With any self-hosted library, you own the login page, styling, database adapter, and upgrades.

When a managed identity provider reduces operational burden

Managed providers (Clerk, Auth0, Supabase Auth, WorkOS) ship hosted login UI, MFA, organizations, and SSO. You call a currentUser() or getSession() helper and skip building account recovery.

The tradeoffs are real: per-monthly-active-user pricing that climbs as you grow, less control over login-screen markup, and a vendor dependency in your critical path. Enterprise SSO features usually sit behind higher tiers. 

Teams building B2B SaaS with SAML requirements often find the math works out; consumer apps with large free tiers often don’t. If your team is weighing whether to self-host auth or bring in a specialist to get the integration right the first time, our guide to where to hire vetted Next.js developers covers what to screen for beyond framework familiarity.

Cookie settings, token storage, and CSRF protections that matter

Session tokens belong in an httpOnly cookie set by the server. Nothing else is safe.

import { cookies } from 'next/headers'

const cookieStore = await cookies()

cookieStore.set('session', signedToken, {

 httpOnly: true,   // JavaScript cannot read it, so XSS cannot steal it

 secure: true,     // HTTPS only

 sameSite: 'lax',  // blocks cross-site POSTs, allows top-level navigation

 path: '/',

 maxAge: 60 * 60 * 24 * 7,

})

Storing a session token in localStorage or a non-httpOnly cookie means any injected script, including one from a compromised npm dependency, can read and exfiltrate it. There is no client-side mitigation for that.

Refresh tokens follow the same rule, with a tighter scope: set path: ‘/api/auth/refresh’ so the browser only sends it to the refresh endpoint. The refresh exchange happens server-side; the browser never sees the new token, only a rewritten cookie.

For CSRF, sameSite: ‘lax’ blocks cross-site form POSTs. Next.js also compares the Origin header against the Host header for Server Actions, which stops the classic cross-origin action call; keep serverActions.allowedOrigins narrow if you add a trusted proxy in front of your app. Add an explicit CSRF token for any endpoint that must accept cross-site requests.

Account for runtime constraints before choosing an integration

Next.js 16’s Proxy always runs on the Node.js runtime, a change from Middleware’s historical Edge default, so direct database drivers are more often viable there now than older articles on this topic suggest. That said, a database round trip on every single request, including prefetched ones, adds latency you often don’t want. 

If the line between Next.js and the Node.js runtime it runs on is fuzzy for your team, our breakdown of Next.js vs Node.js untangles it.

Two things still matter regardless of runtime:

  • Verify a JWT signature with jose (Web Crypto-based, works in both Node.js and Edge) for a fast, I/O-free optimistic check, and save the real database-backed check for the Server Component or data access layer.
  • If you need the Edge runtime for this layer, you have to stay on the deprecated middleware.ts, because proxy.ts can’t run on Edge. On Edge, TCP-based drivers like a direct Postgres connection won’t work, so use an HTTP-based data layer (Neon’s serverless driver, Upstash Redis, Supabase REST) or move the check downstream.
// Fast, I/O-free verification in proxy.ts, regardless of runtime

import { jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.AUTH_SECRET!)

async function readToken(token: string) {

 try {

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

   return payload

 } catch {

   return null

 }

}

A production checklist for maintainable access control

Access control stays maintainable when you write the rules down, test them against direct requests, and re-check them at every framework upgrade. Here is the pre-launch pass.

Use a decision table to document what each application layer can safely enforce

Keep the layer table from earlier in this article in your repo, next to the auth code, and add a project-specific column: which file in your codebase owns each check. New engineers won’t have to guess whether the layout already handled it.

Test direct route hits, expired sessions, privilege changes, and failed callbacks

Run these against a deployed preview, not just localhost:

# 1. Direct hit, no session. Expect a 307 to /login, no protected data in the body.

curl -i https://preview.yourapp.com/dashboard/invoices/4821

# 2. API route, no cookie. Expect 401 and an empty payload.

curl -i https://preview.yourapp.com/api/invoices

# 3. Server Action called directly, bypassing the UI entirely.

curl -i -X POST https://preview.yourapp.com/dashboard \

 -H “Next-Action: <action-id-from-devtools-network-tab>” \

 -H “Content-Type: text/plain;charset=UTF-8” \

 –data ‘[“4821”]’

For the third test, grab the action ID from the Network tab when you trigger the action normally. Then replay it with no session cookie, and with a different user’s cookie against someone else’s record. A pass means 401 or 403 both times.

Also test: an expired session token (set maxAge to 10 seconds locally), a role downgraded mid-session (change the row, then hit an admin route without re-logging in), and a canceled OAuth callback (deny consent at the provider and confirm the app lands on an error page, not a half-created account).

Then confirm the payload itself is clean. Open a protected page, view source, and search the HTML and the RSC payload for a field the current user should never see. If it appears, the fix is at the query, not in the component.

An AI coding assistant helps you sweep a large codebase. Point it at your repo with a specific prompt: “List every file under app/ exporting a Server Action or a Route Handler that does not call auth() or requireUser() before its first database call.” Treat the output as a to-review list, then confirm each hit by reading the file.

Keep provider configuration, secrets, and authorization policies auditable

AUTH_SECRET and provider client secrets belong in your platform’s secret manager, never in a committed .env. Rotate AUTH_SECRET on a schedule, and remember that rotating it invalidates every JWT session at once, so pair it with a maintenance window or a dual-secret grace period.

Keep role and permission definitions in one module rather than as string literals scattered across pages. Reviewers can then read a single file to answer “who can delete an invoice?”

Review authentication dependencies and framework changes before upgrades

Next.js releases have changed auth-relevant behavior more than once, including the middleware.ts-to-proxy.ts rename, the runtime change in version 16, and a past Server Action SSRF issue fixed in version 14.1.1. Before any major upgrade, read the release notes and security advisories for changes to the proxy layer, Server Action encryption keys, and caching defaults, since a caching change can expose a per-user response to another user.

Pin your auth library version, subscribe to its security advisories, and run npm audit on the auth dependency tree as part of CI. Auth.js v4 and v5 have different APIs, so an accidental minor-version drift on a monorepo package can silently change behavior.

Frequently asked questions

How do I add authentication to Next.js?

Break it into three separate pieces rather than one login flow: authentication (verifying identity, typically through a library like Auth.js or a managed provider), session management (a signed cookie that carries a JWT or session ID across requests), and authorization (checking what that identity is allowed to see or do on every single data access). Most Next.js authentication problems come from collapsing these into one check instead of enforcing each at the right layer.

Is middleware enough to protect routes in Next.js?

No. The proxy layer (renamed from middleware.ts to proxy.ts in Next.js 16) is meant for coarse, optimistic checks like redirecting a user with no session cookie before a page renders. 

Next.js’s documentation explicitly says this layer shouldn’t be your only line of defense, partly because a misconfigured route matcher can silently skip a path, and partly because a real vulnerability (CVE-2025-29927) previously let requests bypass middleware-only checks on affected versions. Real protection also has to live in the Server Component, Route Handler, or data access layer that actually reads the data.

Do Server Actions in Next.js need their own authentication check?

Yes. A Server Action compiles to its own POST endpoint with a generated ID, and Next.js does not carry over the session state of the page that renders the button triggering it. Anyone who finds the action’s ID, for example in browser dev tools, can call it directly with arguments of their choosing. Every Server Action needs to verify the session and check ownership or permissions itself, the same way a public API endpoint would.

What is the best authentication library for Next.js App Router?

There isn’t a single best option; it depends on how much you want to own. For a new self-hosted build, start with Better Auth, which keeps sessions in your own database and adds passkeys and two-factor authentication through plugins. Its team also maintains Auth.js (formerly NextAuth.js), which remains a sound choice for apps already running it. 

Managed providers like Clerk, Auth0, or WorkOS reduce operational work (hosted login UI, MFA, SSO) in exchange for per-user pricing and less control over the login screen. Teams with SAML or enterprise SSO requirements often find a managed provider worth the cost; smaller consumer apps often don’t.

Can you check authentication in a Next.js layout?

You can, but a layout check alone doesn’t protect everything under it. A layout doesn’t re-run on every client-side navigation within the same segment, so a session that expires mid-browse can leave a cached layout rendering while a child route fetches data with a dead session. A sibling route segment that fetches its own data also isn’t covered by a parent layout’s check. The safer pattern is to put the real check in a shared, cached function (like a requireUser() helper) called where data is read, not just once in the layout.

Where should you store a session token in Next.js?

In an httpOnly, secure, sameSite cookie set by the server, never in localStorage or a cookie JavaScript can read. An httpOnly cookie can’t be read by client-side scripts, which means even an XSS vulnerability from a compromised npm dependency can’t exfiltrate it. Refresh tokens should follow the same rule, scoped to a narrow cookie path like /api/auth/refresh so the browser sends it only to the endpoint that needs it.

Build security checks into every request path

Auth in the App Router works when each layer does what it can. proxy.ts turns away requests with no cookie before a render starts. Server Components verify the session and gate what they render. Route Handlers and Server Actions each run their own check, because neither inherits anything from the page that called them. Client Components reflect state the server already decided.

Under all of that sits the data access layer, where ownership and role rules live next to the query. Session tokens stay in httpOnly, secure, sameSite cookies, and refresh tokens never reach browser JavaScript. Before launch, hit protected routes with curl and no cookie, replay a Server Action outside the UI, and read the RSC payload for fields the current user should not have.

Auth bugs rarely show up in a demo. They show up in production when someone hits a URL directly or calls a Server Action the UI never exposed. You need engineers who check for that by default, not developers who can wire up a login form. 

Arc pre-vets Next.js developers for technical depth and English fluency, HireAI returns a matched shortlist in seconds, and freelance hires can start in as little as 72 hours.

Hire vetted Next.js developers with Arc →

Written by
The Arc Team