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.
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
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.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— usecache()+cookies() - No
/pages/api/auth— use/app/api/authroute 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/
메타데이터
- post_id
- 2c92cf17de9f
- slug
- jwt-auth-in-next-js-16-app-router-updated-for-2026-2c92cf17de9f
- url
- https://medium.com/@chiragmehta900/jwt-auth-in-next-js-16-app-router-updated-for-2026-2c92cf17de9f
- canonical_url
- https://medium.com/@chiragmehta900/jwt-auth-in-next-js-16-app-router-updated-for-2026-2c92cf17de9f
- author_url
- https://medium.com/@chiragmehta900
- status
- ok
- fetched_at
- 2026-06-20 20:29:01