- Overview - Specifics of Next.js App Router Authentication
- Next.js Authentication Architecture Full Flow
- Middleware-based Authentication (middleware.ts)
- Authentication in Route Handlers
- Authentication in Server Actions
- Accessing Auth State in Server Components
- Managing Auth State in Client Components
- Browser Storage Accessibility Table
- Cookie Configuration in Practice
- JWT Claim Parsing
- CORS Configuration
- Logout and Token Invalidation
- NextAuth.js / Auth.js Comparison
- Security Tradeoffs
- Checklist
- Common Bugs and Misconceptions
- 1. "cookies() can be called anywhere"
- 2. "It is fine to query the DB in middleware"
- 3. "cookies().set() can be called in a Server Component"
- 4. "The jsonwebtoken library can be used on the Edge Runtime"
- 5. "SameSite=Strict is the safest, so always use Strict"
- 6. "The Refresh Token can be set on the same path"
- 7. "Just redirect, without revalidatePath"
- References
SSO Cookie/JWT Authentication Series · React Edition · Current: Next.js Edition · Integration Practical Edition
Overview - Specifics of Next.js App Router Authentication
The Next.js App Router demands a fundamentally different paradigm from traditional React SPA authentication. The client-centric authentication covered in the React edition relies on the browser's document.cookie or on fetch requests, but in Next.js you have to manage authentication state on both the server and the client.
Authentication Differences: Server Components vs Client Components
Server Components are rendered on the server, so they can access the request cookies directly through the cookies() API. Client Components, on the other hand, run in the browser, so they cannot access HttpOnly cookies directly and have to confirm the authentication state through an API call.
// Server Component — direct access through the cookies() API
import { cookies } from 'next/headers'
export default async function DashboardPage() {
const cookieStore = await cookies()
const token = cookieStore.get('access_token')?.value
if (!token) {
redirect('/login')
}
const user = await verifyAndDecodeToken(token)
return <Dashboard user={user} />
}
// Client Component — an API call is required
'use client'
import { useEffect, useState } from 'react'
export function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then((res) => res.json())
.then(setUser)
}, [])
if (!user) return <LoginButton />
return <Profile user={user} />
}
Edge Runtime vs Node.js Runtime
Middleware runs on the Edge Runtime, so Node.js-only modules (jsonwebtoken and the like) cannot be used. You have to use the jose library, which is built on the Web Crypto API, instead. Route Handlers and Server Actions run on the Node.js Runtime by default, but export const runtime = 'edge' lets them run on the Edge as well.
Differences from React SPA
| Category | React SPA | Next.js App Router |
|---|---|---|
| Cookie Access | document.cookie (no HttpOnly) | cookies() API (HttpOnly included) |
| Auth Check Timing | After client rendering | At server rendering (middleware/RSC) |
| Redirect | Client router | Server-side redirect() |
| Token Verify | Delegated to backend API | Direct verification possible in middleware |
| Initial Load | Auth state uncertain (flash) | Confirmed state via SSR |
Next.js Authentication Architecture Full Flow
The full flow of how an authentication request is handled in the Next.js App Router is as follows.
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Client Comp │ │ Form Submit │ │ fetch(/api/...) │ │
│ │ (useAuth) │ │ (Server Act) │ │ credentials: │ │
│ │ │ │ │ │ 'include' │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
└─────────┼───────────────────┼─────────────────────┼─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ middleware.ts (Edge Runtime) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 1. Read the cookie from NextRequest │ │
│ │ 2. Verify the JWT with jose (Edge-compatible) │ │
│ │ 3. Unauthenticated → redirect to /login │ │
│ │ 4. Authenticated → inject user info into headers (opt.) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────────┼───────────────────────────────────┘
▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Server Component │ │ Server Action │ │ Route Handler │
│ read cookies() │ │ read cookies() │ │ set cookies() │
│ parse JWT │ │ set/delete cookie│ │ login/logout │
│ cond. rendering │ │ revalidate │ │ token refresh │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ Backend API │
│ (auth server) │
│ issue/verify JWT│
└──────────────────┘
The core principles are as follows.
- Middleware acts as the first gate for every request and checks the authentication state.
- Route Handlers are responsible for setting and deleting cookies (login, logout, refresh).
- Server Components read the token from the cookie and render the user's information.
- Server Actions handle form-based authentication and server-side logic.
- Client Components either receive the authentication state passed down from the server, or refresh it through the API.
Middleware-based Authentication (middleware.ts)
Middleware is an Edge Function that runs before any request reaches the server. Its job is to verify the token for paths that require authentication and to redirect unauthenticated requests.
// middleware.ts (project root)
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify, type JWTPayload } from 'jose'
interface AuthPayload extends JWTPayload {
sub: string
roles: string[]
email: string
}
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
// path patterns that require authentication
const protectedPaths = ['/dashboard', '/settings', '/api/protected']
// paths accessible without authentication
const publicPaths = ['/login', '/register', '/api/auth']
function isProtectedPath(pathname: string): boolean {
return protectedPaths.some((path) => pathname.startsWith(path))
}
function isPublicPath(pathname: string): boolean {
return publicPaths.some((path) => pathname.startsWith(path))
}
async function verifyToken(token: string): Promise<AuthPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, {
algorithms: ['HS256'],
clockTolerance: 15, // 15 seconds of clock skew tolerance
})
return payload as AuthPayload
} catch (error) {
return null
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// public paths pass through
if (isPublicPath(pathname)) {
return NextResponse.next()
}
// anything that is not a protected path passes through
if (!isProtectedPath(pathname)) {
return NextResponse.next()
}
const token = request.cookies.get('access_token')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
const payload = await verifyToken(token)
if (!payload) {
// the token is not valid, so try a refresh
const refreshToken = request.cookies.get('refresh_token')?.value
if (refreshToken) {
// redirect so that the Route Handler performs the refresh
const refreshUrl = new URL('/api/auth/refresh', request.url)
refreshUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(refreshUrl)
}
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// authenticated request: inject the user info into the request headers (optional)
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', payload.sub)
requestHeaders.set('x-user-roles', JSON.stringify(payload.roles))
return NextResponse.next({
request: {
headers: requestHeaders,
},
})
}
export const config = {
matcher: [
/*
* matches every request path except static files and images
* excludes _next/static, _next/image, favicon.ico
*/
'/((?!_next/static|_next/image|favicon.ico|public).*)',
],
}
A caution: middleware must not make database calls or perform heavy computation. The Edge Runtime is a lightweight execution environment where a fast response is essential. Verifying a token signature (jose's jwtVerify) is fast enough, but work such as querying a token blacklist in a database is better delegated to a Route Handler.
Authentication in Route Handlers
The Route Handler is the core layer that sets and deletes cookies. This is where login, logout, token refresh and fetching the current user's information are implemented.
Login (app/api/auth/login/route.ts)
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { SignJWT } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
interface LoginRequest {
email: string
password: string
}
interface BackendAuthResponse {
user: {
id: string
email: string
name: string
roles: string[]
}
accessToken: string
refreshToken: string
}
export async function POST(request: NextRequest) {
try {
const body: LoginRequest = await request.json()
// send the login request to the backend authentication server
const backendResponse = await fetch(`${process.env.BACKEND_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!backendResponse.ok) {
const error = await backendResponse.json()
return NextResponse.json(
{ error: error.message || 'Login failed.' },
{ status: 401 }
)
}
const data: BackendAuthResponse = await backendResponse.json()
// either issue your own JWT in Next.js, or use the backend token as-is
const response = NextResponse.json({
user: {
id: data.user.id,
email: data.user.email,
name: data.user.name,
roles: data.user.roles,
},
})
// set the Access Token cookie
response.cookies.set('access_token', data.accessToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: 60 * 15, // 15 minutes
...(IS_PRODUCTION && { domain: '.example.com' }),
})
// set the Refresh Token cookie
response.cookies.set('refresh_token', data.refreshToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/api/auth/refresh', // sent only on the refresh path
maxAge: 60 * 60 * 24 * 7, // 7 days
...(IS_PRODUCTION && { domain: '.example.com' }),
})
return response
} catch (error) {
console.error('Login error:', error)
return NextResponse.json({ error: 'An internal server error occurred.' }, { status: 500 })
}
}
Logout (app/api/auth/logout/route.ts)
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST(request: NextRequest) {
try {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access_token')?.value
// ask the backend to invalidate the token (optional: server-side blacklist)
if (accessToken) {
await fetch(`${process.env.BACKEND_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}).catch(() => {
// delete the cookie even if the backend call fails
})
}
const response = NextResponse.json({ success: true })
// delete the cookie — maxAge: 0 expires it immediately
response.cookies.set('access_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
})
response.cookies.set('refresh_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/auth/refresh',
maxAge: 0,
})
return response
} catch (error) {
return NextResponse.json({ error: 'An error occurred while processing the logout.' }, { status: 500 })
}
}
Token Refresh (app/api/auth/refresh/route.ts)
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const refreshToken = request.cookies.get('refresh_token')?.value
if (!refreshToken) {
return NextResponse.json({ error: 'There is no refresh token.' }, { status: 401 })
}
try {
const backendResponse = await fetch(`${process.env.BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!backendResponse.ok) {
// refresh failed → delete every token cookie
const response = NextResponse.json(
{ error: 'Your session has expired. Please log in again.' },
{ status: 401 }
)
response.cookies.set('access_token', '', { path: '/', maxAge: 0 })
response.cookies.set('refresh_token', '', { path: '/api/auth/refresh', maxAge: 0 })
return response
}
const data = await backendResponse.json()
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
const response = NextResponse.json({ success: true })
response.cookies.set('access_token', data.accessToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: 60 * 15,
...(IS_PRODUCTION && { domain: '.example.com' }),
})
// when Refresh Token Rotation is applied
if (data.refreshToken) {
response.cookies.set('refresh_token', data.refreshToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/api/auth/refresh',
maxAge: 60 * 60 * 24 * 7,
...(IS_PRODUCTION && { domain: '.example.com' }),
})
}
return response
} catch (error) {
return NextResponse.json({ error: 'An error occurred while refreshing the token.' }, { status: 500 })
}
}
Current User Info (app/api/auth/me/route.ts)
// app/api/auth/me/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function GET(request: NextRequest) {
const token = request.cookies.get('access_token')?.value
if (!token) {
return NextResponse.json({ user: null }, { status: 401 })
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return NextResponse.json({
user: {
id: payload.sub,
email: payload.email,
name: payload.name,
roles: payload.roles,
},
})
} catch (error) {
return NextResponse.json({ user: null }, { status: 401 })
}
}
Authentication in Server Actions
A Server Action is an async function marked with the 'use server' directive that handles form submissions and server-side data changes. Because it can access the cookies() API directly, authentication logic can be handled safely on the server.
// app/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
interface LoginFormState {
error?: string
success?: boolean
}
export async function loginAction(
prevState: LoginFormState,
formData: FormData
): Promise<LoginFormState> {
const email = formData.get('email') as string
const password = formData.get('password') as string
if (!email || !password) {
return { error: 'Please enter your email and password.' }
}
try {
const response = await fetch(`${process.env.BACKEND_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
const data = await response.json()
return { error: data.message || 'The email or the password is incorrect.' }
}
const data = await response.json()
const cookieStore = await cookies()
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
cookieStore.set('access_token', data.accessToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: 60 * 15,
})
cookieStore.set('refresh_token', data.refreshToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/api/auth/refresh',
maxAge: 60 * 60 * 24 * 7,
})
} catch (error) {
return { error: 'Cannot connect to the server.' }
}
revalidatePath('/')
redirect('/dashboard')
}
export async function logoutAction(): Promise<void> {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access_token')?.value
// ask the backend to invalidate the token
if (accessToken) {
await fetch(`${process.env.BACKEND_URL}/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
}).catch(() => {})
}
cookieStore.delete('access_token')
cookieStore.delete('refresh_token')
revalidatePath('/')
redirect('/login')
}
// app/login/page.tsx — a login form that uses a Server Action
'use client'
import { useActionState } from 'react'
import { loginAction } from '@/app/actions/auth'
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(loginAction, {})
return (
<form action={formAction}>
{state.error && <div className="rounded-md bg-red-50 p-3 text-red-600">{state.error}</div>}
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required autoComplete="email" />
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
/>
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Logging in...' : 'Log in'}
</button>
</form>
)
}
Accessing Auth State in Server Components
A Server Component can read HttpOnly cookies directly through the cookies() API, so it can confirm the authentication state without an extra API call.
// lib/auth.ts — server-side authentication utilities
import { cookies } from 'next/headers'
import { jwtVerify, type JWTPayload } from 'jose'
import { cache } from 'react'
interface User {
id: string
email: string
name: string
roles: string[]
}
interface AuthPayload extends JWTPayload {
sub: string
email: string
name: string
roles: string[]
}
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
// React cache prevents duplicate verification within the same request
export const getAuthUser = cache(async (): Promise<User | null> => {
const cookieStore = await cookies()
const token = cookieStore.get('access_token')?.value
if (!token) return null
try {
const { payload } = (await jwtVerify(token, JWT_SECRET)) as { payload: AuthPayload }
return {
id: payload.sub,
email: payload.email,
name: payload.name,
roles: payload.roles,
}
} catch (error) {
return null
}
})
export async function requireAuth(): Promise<User> {
const user = await getAuthUser()
if (!user) {
const { redirect } = await import('next/navigation')
redirect('/login')
}
return user
}
// app/dashboard/page.tsx — an authenticated Server Component
import { requireAuth } from '@/lib/auth'
import { LogoutButton } from '@/components/LogoutButton'
export default async function DashboardPage() {
const user = await requireAuth()
return (
<div>
<header>
<h1>Dashboard</h1>
<p>Welcome, {user.name}.</p>
<span className="text-sm text-gray-500">{user.email}</span>
<LogoutButton />
</header>
{user.roles.includes('admin') && (
<section>
<h2>Admin menu</h2>
{/* admin-only content */}
</section>
)}
<section>
<h2>My information</h2>
<p>Roles: {user.roles.join(', ')}</p>
</section>
</div>
)
}
Managing Auth State in Client Components
In Client Components you either manage the authentication information handed down from the server through a Context, or keep it current by polling the API.
// contexts/AuthContext.tsx
'use client'
import { createContext, useContext, useCallback, useMemo, type ReactNode } from 'react'
import useSWR from 'swr'
interface User {
id: string
email: string
name: string
roles: string[]
}
interface AuthContextType {
user: User | null
isLoading: boolean
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
const fetcher = (url: string) =>
fetch(url, { credentials: 'include' }).then((res) => {
if (!res.ok) return { user: null }
return res.json()
})
export function AuthProvider({
children,
initialUser,
}: {
children: ReactNode
initialUser: User | null
}) {
const { data, mutate, isLoading } = useSWR('/api/auth/me', fetcher, {
fallbackData: { user: initialUser },
revalidateOnFocus: true,
revalidateInterval: 5 * 60 * 1000, // refresh every 5 minutes
dedupingInterval: 60 * 1000,
})
const user = data?.user ?? null
const login = useCallback(
async (email: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Login failed.')
}
await mutate()
},
[mutate]
)
const logout = useCallback(async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
await mutate({ user: null }, { revalidate: false })
}, [mutate])
const refresh = useCallback(async () => {
await mutate()
}, [mutate])
const value = useMemo(
() => ({
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
refresh,
}),
[user, isLoading, login, logout, refresh]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth can only be used inside an AuthProvider.')
}
return context
}
// app/layout.tsx — passing the initial value from a Server Component into the Client Context
import { getAuthUser } from '@/lib/auth'
import { AuthProvider } from '@/contexts/AuthContext'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getAuthUser()
return (
<html lang="ko">
<body>
<AuthProvider initialUser={user}>{children}</AuthProvider>
</body>
</html>
)
}
Browser Storage Accessibility Table
| Storage | JS Access | Auto Server Send | XSS Vuln. | CSRF Vuln. | Next.js Server Access |
|---|---|---|---|---|---|
| localStorage | O | X | O | X | X |
| sessionStorage | O | X | O | X | X |
| General Cookie | O | O | O | O | O |
| HttpOnly Cookie | X | O | X | O (defended by SameSite) | O (cookies() API) |
| Authorization Header | O (Code controlled) | X (Manual) | O (Storage dependent) | X | X (Not directly) |
Why the HttpOnly Cookie is recommended in Next.js:
- Server-side access is possible: with the
cookies()API it is accessible everywhere - Server Components, Server Actions, Route Handlers and middleware. - XSS defense: JavaScript cannot reach it, which removes the risk of token theft.
- Automatic transmission: the browser includes the cookie on every request, so no separate interceptor is needed.
- CSRF defense: setting
SameSite=LaxorStrictblocks cross-site requests. - SSR compatible: the authentication state is already settled at the first server render, so there is no flash.
Cookie Configuration in Practice
Development vs Production Cookie Settings
// lib/cookie-config.ts
export interface CookieConfig {
httpOnly: boolean
secure: boolean
sameSite: 'strict' | 'lax' | 'none'
path: string
maxAge: number
domain?: string
}
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN // .example.com
export const ACCESS_TOKEN_COOKIE: CookieConfig = {
httpOnly: true,
secure: IS_PRODUCTION, // development: false (HTTP), production: true (HTTPS)
sameSite: IS_PRODUCTION ? 'lax' : 'lax',
path: '/', // sent on every path
maxAge: 60 * 15, // 15 minutes
...(IS_PRODUCTION && COOKIE_DOMAIN && { domain: COOKIE_DOMAIN }),
}
export const REFRESH_TOKEN_COOKIE: CookieConfig = {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: IS_PRODUCTION ? 'strict' : 'lax',
path: '/api/auth/refresh', // sent only on the refresh endpoint
maxAge: 60 * 60 * 24 * 7, // 7 days
...(IS_PRODUCTION && COOKIE_DOMAIN && { domain: COOKIE_DOMAIN }),
}
// usage example
// response.cookies.set('access_token', token, ACCESS_TOKEN_COOKIE)
Here is what each option means.
| Option | Description | Recommended Value |
|---|---|---|
httpOnly | Block JS access | true (Always) |
secure | Send only over HTTPS | production: true, development: false |
sameSite | Cross-site request control | lax (General), strict (Refresh) |
path | Cookie send path restriction | Access: /, Refresh: /api/auth/refresh |
maxAge | Cookie lifetime (seconds) | Access: 900, Refresh: 604800 |
domain | Cookie valid domain | .example.com (When sharing subdomains) |
JWT Claim Parsing
The jose library works on both the Edge Runtime and Node.js, and it performs JWT verification and claim extraction.
// lib/jwt.ts
import { jwtVerify, SignJWT, type JWTPayload } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export interface TokenClaims extends JWTPayload {
sub: string // user ID
email: string // email
name: string // name
roles: string[] // list of roles
iat: number // issued at
exp: number // expires at
iss: string // issuer
jti: string // unique token ID (for the blacklist)
}
export async function verifyAccessToken(token: string): Promise<TokenClaims> {
const { payload } = await jwtVerify(token, JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
clockTolerance: 15,
})
// confirm that the required claims are present
if (!payload.sub || !payload.email) {
throw new Error('A required claim is missing.')
}
return payload as TokenClaims
}
export async function createAccessToken(user: {
id: string
email: string
name: string
roles: string[]
}): Promise<string> {
return new SignJWT({
email: user.email,
name: user.name,
roles: user.roles,
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime('15m')
.setIssuer('https://auth.example.com')
.setJti(crypto.randomUUID())
.sign(JWT_SECRET)
}
// select only the fields to hand to the frontend
export function sanitizeUserForClient(claims: TokenClaims) {
// sensitive fields (jti, iat, exp, iss) are excluded
return {
id: claims.sub,
email: claims.email,
name: claims.name,
roles: claims.roles,
}
}
The principle for what reaches the frontend: you should not hand every claim contained in the JWT to the client. Select only display fields such as sub, email, name and roles, and keep internal fields such as jti, iss and exp on the server.
CORS Configuration
Global CORS Headers in next.config.js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// CORS configuration for the API paths
source: '/api/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: process.env.ALLOWED_ORIGIN || 'https://app.example.com',
},
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, PUT, DELETE, OPTIONS',
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization',
},
{
key: 'Access-Control-Allow-Credentials',
value: 'true',
},
{
key: 'Access-Control-Max-Age',
value: '86400',
},
],
},
]
},
}
module.exports = nextConfig
CORS Handling in Route Handlers
// app/api/auth/login/route.ts (handling the CORS preflight)
import { NextRequest, NextResponse } from 'next/server'
const ALLOWED_ORIGINS = ['https://app.example.com', 'https://admin.example.com']
function getCorsHeaders(origin: string | null) {
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin)
return {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Credentials': 'true',
}
}
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get('origin')
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(origin),
})
}
export async function POST(request: NextRequest) {
const origin = request.headers.get('origin')
// ... login logic ...
const response = NextResponse.json({ success: true })
Object.entries(getCorsHeaders(origin)).forEach(([key, value]) => {
response.headers.set(key, value)
})
return response
}
CORS Handling in Middleware
You can also handle the same CORS logic centrally in the middleware. In that case there is no need to repeat the CORS code in every Route Handler.
// the CORS handling logic inside middleware.ts (excerpt)
if (request.method === 'OPTIONS') {
const origin = request.headers.get('origin')
if (origin && ALLOWED_ORIGINS.includes(origin)) {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
})
}
}
Logout and Token Invalidation
Server-side Blacklist + Cookie Deletion
Simply deleting the cookie cannot invalidate a token that has already been stolen. A complete logout requires a server-side token blacklist.
// lib/token-blacklist.ts
// an example token blacklist using Redis
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
export async function blacklistToken(jti: string, expiresAt: number): Promise<void> {
const ttl = expiresAt - Math.floor(Date.now() / 1000)
if (ttl > 0) {
await redis.setex(`blacklist:${jti}`, ttl, '1')
}
}
export async function isTokenBlacklisted(jti: string): Promise<boolean> {
const result = await redis.get(`blacklist:${jti}`)
return result === '1'
}
Server Action Based Logout
// app/actions/auth.ts (logoutAction — with the blacklist)
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { verifyAccessToken } from '@/lib/jwt'
import { blacklistToken } from '@/lib/token-blacklist'
export async function logoutAction(): Promise<void> {
const cookieStore = await cookies()
const token = cookieStore.get('access_token')?.value
if (token) {
try {
const claims = await verifyAccessToken(token)
// add it to the blacklist using jti and exp
await blacklistToken(claims.jti, claims.exp!)
} catch {
// ignore the case where the token has already expired
}
}
cookieStore.delete('access_token')
cookieStore.delete('refresh_token')
revalidatePath('/', 'layout')
redirect('/login')
}
// components/LogoutButton.tsx
'use client'
import { logoutAction } from '@/app/actions/auth'
export function LogoutButton() {
return (
<form action={logoutAction}>
<button type="submit">Log out</button>
</form>
)
}
NextAuth.js / Auth.js Comparison
Here is a comparison of the tradeoffs between implementing it yourself and using NextAuth.js (Auth.js).
| Category | Direct Implementation | NextAuth.js / Auth.js |
|---|---|---|
| Flexibility | Full control | Depends on framework conventions |
| Implementation Cost | High (security expertise needed) | Low (quick start) |
| OAuth Integration | Direct Implementation | 40+ providers built-in |
| Session Management | Manual (JWT/DB) | Automatic (JWT/DB choice) |
| Token Refresh | Direct Implementation | Built-in (OAuth only) |
| SSO Integration | Fully customizable | Limited |
| Learning Curve | Auth fundamentals needed | NextAuth API learning |
When a direct implementation is the right fit:
- When you have to integrate with an existing authentication server (SSO)
- When you need a fine-grained token management policy
- When you need cookie sharing across multiple domains/services
- When you have to control the authentication flow completely
When NextAuth.js is the right fit:
- When all you need is social login through Google, GitHub and the like
- When rapid MVP development is the goal
- When expertise in authentication security is lacking
Security Tradeoffs
XSS (Cross-Site Scripting)
If you use HttpOnly cookies, the token cannot be stolen through document.cookie. An XSS attacker can, however, call fetch('/api/auth/me') to take the user's information, or call an authenticated API on your behalf. Content rendered in a Server Component is stronger against XSS because no client JavaScript is involved.
CSRF (Cross-Site Request Forgery)
A SameSite=Lax cookie is not included in cross-site POST requests, which blocks most CSRF attacks. Next.js manages the CSRF token for Server Actions automatically, so no separate handling is needed.
Token Theft & Replay
Combining a short Access Token expiry (15 minutes) with Refresh Token Rotation minimizes the damage of token theft. Adding a blacklist mechanism lets you invalidate a stolen token immediately.
Security Benefits of Server Components
- A Server Component runs only on the server, so the authentication logic and the secret key are never exposed to the client.
- The
cookies()API can only be called on the server, so the client cannot tamper with it. - Data fetching calls the backend directly from the server, so the token never passes through the browser.
Checklist
Here are the items to check when implementing authentication in Next.js.
- Every authentication cookie set with
httpOnly: true -
secure: trueapplied in the production environment -
sameSite: 'lax'or stricter (CSRF defense) - Access Token expiry of 15 minutes or less
- Refresh Token path restricted to a separate path (
/api/auth/refresh) - Refresh Token Rotation implemented
- Authentication check for protected paths in the middleware
-
React.cacheused in Server Components to prevent duplicate verification - Server-side token blacklist applied on logout
- An Edge Runtime-compatible library used (
jose) - CORS
credentials: trueconfigured (for cross-domain) - Cookie settings separated per environment (development/production)
- The JWT claims handed to the frontend kept to a minimum
- Internal information kept out of error messages
-
callbackUrlvalidated (to prevent open redirects)
Common Bugs and Misconceptions
1. "cookies() can be called anywhere"
cookies() can only be called in a Server Component, a Server Action or a Route Handler. Calling it in a Client Component ('use client') produces a build error.
2. "It is fine to query the DB in middleware"
Middleware runs on the Edge Runtime, and it runs for every request. A DB call is a leading cause of response latency. Perform only the token signature verification there, and do the detailed permission checks in a Route Handler or a Server Component.
3. "cookies().set() can be called in a Server Component"
cookies().get() can be called in a Server Component, but cookies().set() and cookies().delete() can only be called in a Server Action or a Route Handler. That is because the response headers cannot be modified during a Server Component's rendering phase.
4. "The jsonwebtoken library can be used on the Edge Runtime"
jsonwebtoken depends on Node.js's crypto module, so it does not work on the Edge Runtime. In middleware you must use the jose library.
5. "SameSite=Strict is the safest, so always use Strict"
SameSite=Strict does not send the cookie when the user arrives by clicking a link on an external site. That creates a UX problem where users who arrive through social media or an email link have to log in again every time. Using Lax for the Access Token and Strict for the Refresh Token is the balanced strategy.
6. "The Refresh Token can be set on the same path"
If you set the Refresh Token's path to /, the Refresh Token is sent unnecessarily on every request. Beyond the wasted network bandwidth, it widens the attack surface. Restrict the path to /api/auth/refresh so that it is only sent on the requests that need it.
7. "Just redirect, without revalidatePath"
If you change cookies in a Server Action and then only call redirect(), a cached page may still show the previous authentication state. You have to call revalidatePath('/', 'layout') to invalidate the cache for the whole path including the layout.
References
- Next.js Authentication official guide — official documentation on App Router authentication patterns
- Next.js Middleware documentation — middleware configuration and usage
- Next.js cookies() API — the server-side cookie access API
- Next.js Server Actions — data mutation with Server Actions
- jose library on GitHub — an Edge Runtime-compatible JWT library
- RFC 7519 - JSON Web Token — the JWT standard specification
- RFC 6265 - HTTP State Management (Cookies) — the cookie standard specification
- OWASP Session Management Cheat Sheet — session management security guidelines
- NextAuth.js (Auth.js) official documentation — the Next.js authentication library
- Vercel Blog - Understanding Next.js Middleware — an explanation of the middleware architecture
- MDN - SameSite cookies — an explanation of the SameSite cookie attribute
- OWASP Cross-Site Request Forgery Prevention — a CSRF defense guide