JWT Authentication in Next.js 15 App Router: A Complete Guide
The right way to do JWT authentication in Next.js 15 with App Router — middleware, refresh tokens, secure cookies, role-based access, and server components that know who you are.
Why JWT for App Router?
Next.js 15 App Router is server-first. Server Components can call your database directly. JWT auth — signed, short-lived, and cookie-stored — gives you stateless authentication that works in both Server Components and Middleware.
Token Strategy
| Token | Lifetime | Storage |
|---|---|---|
| Access | 15 minutes | HttpOnly Secure Cookie |
| Refresh | 7 days | HttpOnly Secure Cookie (different path) |
Never put JWTs in localStorage. Ever.
Signing and Verifying with `jose`
// lib/auth.ts
import { SignJWT, jwtVerify } from 'jose'const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function signAccess(payload: { sub: string; role: string }) { return new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('15m') .sign(secret) }
export async function verifyAccess(token: string) { const { payload } = await jwtVerify(token, secret) return payload as { sub: string; role: string } } ```
Login Server Action
'use server'
import { cookies } from 'next/headers'export async function login(email: string, password: string) { const user = await authenticate(email, password) if (!user) return { error: 'Invalid credentials' }
const access = await signAccess({ sub: user.id, role: user.role }) const refresh = await signRefresh({ sub: user.id })
const cookieStore = await cookies() cookieStore.set('access', access, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 15, }) cookieStore.set('refresh', refresh, { httpOnly: true, secure: true, sameSite: 'lax', path: '/api/refresh', maxAge: 60 60 24 * 7, }) return { ok: true } } ```
Middleware-Based Protection
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { verifyAccess } from '@/lib/auth'const PUBLIC = ['/', '/login', '/about', '/blog']
export async function middleware(req: NextRequest) { const { pathname } = req.nextUrl if (PUBLIC.includes(pathname) || pathname.startsWith('/_next')) return NextResponse.next()
const token = req.cookies.get('access')?.value if (!token) return NextResponse.redirect(new URL('/login', req.url))
try { const payload = await verifyAccess(token) const res = NextResponse.next() res.headers.set('x-user-id', payload.sub) res.headers.set('x-user-role', payload.role) return res } catch { return NextResponse.redirect(new URL('/login', req.url)) } }
export const config = { matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'] } ```
Reading User in a Server Component
import { headers } from 'next/headers'export default async function DashboardPage() {
const h = await headers()
const userId = h.get('x-user-id')!
const userRole = h.get('x-user-role')!
const orders = await db.order.findMany({ where: { userId } })
return
Refresh Flow
// app/api/refresh/route.ts
export async function POST(req: Request) {
const refresh = req.headers.get('cookie')?.match(/refresh=([^;]+)/)?.[1]
if (!refresh) return new Response('no refresh', { status: 401 })try { const { payload } = await jwtVerify(refresh, refreshSecret) const user = await db.user.findUnique({ where: { id: payload.sub as string } }) if (!user || user.refreshTokenVersion !== payload.v) throw new Error('revoked')
const access = await signAccess({ sub: user.id, role: user.role })
return new Response(null, {
status: 204,
headers: { 'Set-Cookie': access=${access}; HttpOnly; Secure; Path=/; Max-Age=900 }
})
} catch {
return new Response('invalid', { status: 401 })
}
}
```
Revocation: The Refresh Version Pattern
To force-logout a user, increment their refreshTokenVersion in the DB. All refresh attempts fail until they log back in. Access tokens expire naturally in 15 minutes — short-lived by design.