LabHub

Blog

Next.js App Router Authentication Practical Guide - Middleware, Server Action, HttpOnly Cookie, SSR Auth Complete Mastery

한국어English日本語

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

CategoryReact SPANext.js App Router
Cookie Accessdocument.cookie (no HttpOnly)cookies() API (HttpOnly included)
Auth Check TimingAfter client renderingAt server rendering (middleware/RSC)
RedirectClient routerServer-side redirect()
Token VerifyDelegated to backend APIDirect verification possible in middleware
Initial LoadAuth 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.

  1. Middleware acts as the first gate for every request and checks the authentication state.
  2. Route Handlers are responsible for setting and deleting cookies (login, logout, refresh).
  3. Server Components read the token from the cookie and render the user's information.
  4. Server Actions handle form-based authentication and server-side logic.
  5. 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

StorageJS AccessAuto Server SendXSS Vuln.CSRF Vuln.Next.js Server Access
localStorageOXOXX
sessionStorageOXOXX
General CookieOOOOO
HttpOnly CookieXOXO (defended by SameSite)O (cookies() API)
Authorization HeaderO (Code controlled)X (Manual)O (Storage dependent)XX (Not directly)

Why the HttpOnly Cookie is recommended in Next.js:

  1. Server-side access is possible: with the cookies() API it is accessible everywhere - Server Components, Server Actions, Route Handlers and middleware.
  2. XSS defense: JavaScript cannot reach it, which removes the risk of token theft.
  3. Automatic transmission: the browser includes the cookie on every request, so no separate interceptor is needed.
  4. CSRF defense: setting SameSite=Lax or Strict blocks cross-site requests.
  5. SSR compatible: the authentication state is already settled at the first server render, so there is no flash.
// 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.

OptionDescriptionRecommended Value
httpOnlyBlock JS accesstrue (Always)
secureSend only over HTTPSproduction: true, development: false
sameSiteCross-site request controllax (General), strict (Refresh)
pathCookie send path restrictionAccess: /, Refresh: /api/auth/refresh
maxAgeCookie lifetime (seconds)Access: 900, Refresh: 604800
domainCookie 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

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).

CategoryDirect ImplementationNextAuth.js / Auth.js
FlexibilityFull controlDepends on framework conventions
Implementation CostHigh (security expertise needed)Low (quick start)
OAuth IntegrationDirect Implementation40+ providers built-in
Session ManagementManual (JWT/DB)Automatic (JWT/DB choice)
Token RefreshDirect ImplementationBuilt-in (OAuth only)
SSO IntegrationFully customizableLimited
Learning CurveAuth fundamentals neededNextAuth API learning

When a direct implementation is the right fit:

When NextAuth.js is the right fit:

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

Checklist

Here are the items to check when implementing authentication in Next.js.

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

  1. Next.js Authentication official guide — official documentation on App Router authentication patterns
  2. Next.js Middleware documentation — middleware configuration and usage
  3. Next.js cookies() API — the server-side cookie access API
  4. Next.js Server Actions — data mutation with Server Actions
  5. jose library on GitHub — an Edge Runtime-compatible JWT library
  6. RFC 7519 - JSON Web Token — the JWT standard specification
  7. RFC 6265 - HTTP State Management (Cookies) — the cookie standard specification
  8. OWASP Session Management Cheat Sheet — session management security guidelines
  9. NextAuth.js (Auth.js) official documentation — the Next.js authentication library
  10. Vercel Blog - Understanding Next.js Middleware — an explanation of the middleware architecture
  11. MDN - SameSite cookies — an explanation of the SameSite cookie attribute
  12. OWASP Cross-Site Request Forgery Prevention — a CSRF defense guide

Comments

No comments yet.

Sign in to leave a comment