Skip to content
← All posts
084 min read

JWT Auth in Next.js 16 App Router — Updated for 2026

  • app-router
  • nextjs
  • web-development
JWT Auth in Next.js 16 App Router — Updated for 2026 — by Chirag Mehta
JWT Auth in Next.js 16 App Router — Updated for 2026

The patterns for JWT auth in Next.js changed significantly. Here’s the current best practice — no outdated Pages Router patterns.

PUBLISH METADATA

  • Tags: Next.js, Authentication, JavaScript, Web Development, Security
  • Description: A complete 2026 guide to implementing JWT authentication in Next.js 16 App Router — covering token storage, middleware-based route protection, refresh token rotation, and server component auth patterns.
  • Suggested publish date: Week 3 — Thursday
  • Suggested cover image: Next.js logo with a lock/JWT token icon. Dark background, blue accent.

Introduction

My old article on JWT auth in Next.js (from 2024) still gets reads. But it used the Pages Router pattern with getServerSideProps. In the App Router, auth works completely differently.

This is the updated, App Router-native approach for 2026. We’ll implement:

  • JWT access + refresh token pair
  • HTTP-only cookie storage (no localStorage)
  • Middleware-based route protection
  • Server Component auth (no prop drilling)
  • Automatic token refresh

Why HTTP-Only Cookies, Not localStorage

Why HTTP-Only Cookies, Not localStorage
Why HTTP-Only Cookies, Not localStorage

This question still comes up. Short answer:

StorageXSS VulnerableCSRF VulnerablelocalStorage✅ YES — JS can steal tokensNoHTTP-only CookieNo — JS can’t access✅ YES (mitigated with SameSite)HTTP-only + SameSite=StrictNoNo

Use HTTP-only cookies with SameSite=Strict. Done.

Step 1: Token Generation Utilities

// lib/auth/tokens.ts
import { SignJWT, jwtVerify } from 'jose'
const ACCESS_SECRET = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET!)
const REFRESH_SECRET = new TextEncoder().encode(process.env.JWT_REFRESH_SECRET!)
export async function signAccessToken(userId: string) {
return new SignJWT({ userId, type: 'access' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('15m')
.setIssuedAt()
.sign(ACCESS_SECRET)
}
export async function signRefreshToken(userId: string) {
return new SignJWT({ userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.setIssuedAt()
.sign(REFRESH_SECRET)
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, ACCESS_SECRET)
return payload as { userId: string; type: string }
}
export async function verifyRefreshToken(token: string) {
const { payload } = await jwtVerify(token, REFRESH_SECRET)
return payload as { userId: string; type: string }
}

Step 2: Login Server Action

// app/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { signAccessToken, signRefreshToken } from '@/lib/auth/tokens'
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'
export async function login(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
  const user = await db.user.findUnique({ where: { email } })
if (!user) return { error: 'Invalid credentials' }
  const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) return { error: 'Invalid credentials' }
  const accessToken = await signAccessToken(user.id)
const refreshToken = await signRefreshToken(user.id)
  // Store refresh token in DB for rotation tracking
await db.refreshToken.create({
data: { token: refreshToken, userId: user.id, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }
})
  const cookieStore = cookies()
  cookieStore.set('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60, // 15 minutes
path: '/',
})
  cookieStore.set('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 days
path: '/api/auth/refresh', // Only sent to refresh endpoint
})
  redirect('/dashboard')
}
export async function logout() {
const cookieStore = cookies()
cookieStore.delete('access_token')
cookieStore.delete('refresh_token')
redirect('/login')
}

Step 3: Middleware Route Protection

Middleware Route Protection
Middleware Route Protection
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyAccessToken } from '@/lib/auth/tokens'
const PROTECTED_ROUTES = ['/dashboard', '/settings', '/api/v1']
const AUTH_ROUTES = ['/login', '/signup']
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname
const isProtected = PROTECTED_ROUTES.some(route => path.startsWith(route))
const isAuthRoute = AUTH_ROUTES.some(route => path.startsWith(route))
  const accessToken = request.cookies.get('access_token')?.value
  if (isProtected) {
if (!accessToken) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
await verifyAccessToken(accessToken)
return NextResponse.next()
} catch {
// Token expired or invalid — redirect to refresh or login
const response = NextResponse.redirect(new URL('/api/auth/refresh', request.url))
response.headers.set('x-redirect-to', path) // Remember where they were going
return response
}
}
  if (isAuthRoute && accessToken) {
try {
await verifyAccessToken(accessToken)
return NextResponse.redirect(new URL('/dashboard', request.url))
} catch {
// Expired token on auth page — let them log in again
}
}
  return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Step 4: Token Refresh Route

// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyRefreshToken, signAccessToken } from '@/lib/auth/tokens'
import { cookies } from 'next/headers'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
const refreshToken = request.cookies.get('refresh_token')?.value
const redirectTo = request.headers.get('x-redirect-to') || '/dashboard'
  if (!refreshToken) {
return NextResponse.redirect(new URL('/login', request.url))
}
  try {
const { userId } = await verifyRefreshToken(refreshToken)
    // Check token is in DB (not revoked)
const storedToken = await db.refreshToken.findUnique({
where: { token: refreshToken }
})
if (!storedToken || storedToken.expiresAt < new Date()) {
throw new Error('Token revoked or expired')
}
    // Issue new access token
const newAccessToken = await signAccessToken(userId)
const cookieStore = cookies()
    cookieStore.set('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60,
path: '/',
})
    return NextResponse.redirect(new URL(redirectTo, request.url))
} catch {
return NextResponse.redirect(new URL('/login', request.url))
}
}

Step 5: Auth Helper for Server Components

// lib/auth/session.ts
import { cookies } from 'next/headers'
import { verifyAccessToken } from './tokens'
import { db } from '@/lib/db'
import { cache } from 'react'
// `cache` deduplicates calls within one request
export const getCurrentUser = cache(async () => {
const token = cookies().get('access_token')?.value
if (!token) return null
  try {
const { userId } = await verifyAccessToken(token)
return db.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true, role: true }
})
} catch {
return null
}
})
// app/dashboard/page.tsx
import { getCurrentUser } from '@/lib/auth/session'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
  return (
<div>
<h1>Welcome, {user.name}</h1>
</div>
)
}

No prop drilling. No context provider for auth. Just getCurrentUser() anywhere in your Server Component tree.

Conclusion

JWT auth in Next.js 16 App Router is cleaner than the old Pages Router approach once you understand the primitives: Server Actions for login/logout, middleware for route protection, and cache() for session sharing across server components.

The key differences from 2024 patterns:

  • No getServerSideProps — use cache() + cookies()
  • No /pages/api/auth — use /app/api/auth route handlers
  • Forms use Server Actions directly, not fetch calls

#Nextjs #Authentication #JavaScript #WebDevelopment #Security #JWT #AppRouter #React #FullStack

I’m a Full-Stack Developer specializing in React, Next.js, and Node.js.
👉 Connect with me: https://www.linkedin.com/in/chiragmehta900/

Author card for Chirag Mehta, Full-Stack Developer specializing in React, Next.js and Node.js. Links: github.com/chiragmehta900, linkedin.com/in/chiragmehta900, medium.com/@chiragmehta900.
Chirag Mehta

Originally published on Medium

Clap, comment or follow along there

Read on Medium →