// LOADING
// LOADING
// LOADING_ARTICLE
Token-based authentication is now the default for most APIs, but the gap between a working implementation and a secure one is wider than it looks. Access tokens expire quickly by design; refresh tokens are what let users stay logged in without re-entering credentials. Getting this flow right matters—getting it wrong exposes users to session hijacking.
This post covers the mechanics of the access/refresh token pattern, token rotation, and secure storage. If you want background on why JWTs in localStorage are a bad idea, read Why You Should Never Store JWT in LocalStorage first.
The pattern has two tokens with very different lifetimes:
Authorization header./auth/refresh endpoint.When the access token expires, the client silently requests a new one using the refresh token—without prompting the user to log in again. The server validates the refresh token, issues a new access token, and optionally issues a new refresh token (rotation).
typescript// POST /api/auth/refresh — Next.js Route Handler import { NextRequest, NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; import { findRefreshToken, rotateRefreshToken } from '@/lib/tokens'; export async function POST(req: NextRequest) { const refreshToken = req.cookies.get('refresh_token')?.value; if (!refreshToken) { return NextResponse.json({ error: 'Missing refresh token' }, { status: 401 }); } let payload: { userId: string; jti: string }; try { payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as typeof payload; } catch { return NextResponse.json({ error: 'Invalid or expired refresh token' }, { status: 401 }); } // Check token is in the store and hasn't been used before const storedToken = await findRefreshToken(payload.jti); if (!storedToken) { // Possible reuse — invalidate entire family await revokeTokenFamily(payload.userId); return NextResponse.json({ error: 'Token reuse detected' }, { status: 401 }); } // Rotate: delete old, issue new const { newAccessToken, newRefreshToken } = await rotateRefreshToken(payload); const response = NextResponse.json({ accessToken: newAccessToken }); response.cookies.set('refresh_token', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/auth/refresh', maxAge: 60 * 60 * 24 * 30, // 30 days }); return response; }
Rotation means every use of a refresh token produces a new one—the old one is immediately invalidated. This limits the damage from a stolen token: the attacker gets one use before the legitimate client rotates it, which should trigger a reuse alert.
The pattern above uses a jti (JWT ID) claim tied to a server-side store. Each refresh token has a unique ID recorded in a database or Redis. On refresh:
jti in the store.jti.Without step 4, a stolen token can keep refreshing forever. This family-revocation approach is described in the OAuth 2.0 Security BCP (RFC 9700).
Storage is where most implementations go wrong. The rules are straightforward:
HttpOnly, Secure, SameSite=Strict cookie scoped to /api/auth/refresh. JavaScript cannot read it, so XSS cannot steal it.localStorage, never sessionStorage. It disappears on page reload—that is intentional; a silent refresh restores it.This is directly related to the concerns in How Modern Authentication Works and the broader discussion of Common Web Security Threats.
A common client-side pattern uses an Axios interceptor (or a fetch wrapper) to detect 401 responses and trigger a refresh before retrying:
typescriptlet accessToken: string | null = null; async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> { const doRequest = (token: string | null) => fetch(url, { ...options, headers: { ...options.headers, Authorization: token ? `Bearer ${token}` : '' }, }); let res = await doRequest(accessToken); if (res.status === 401) { // Attempt silent refresh const refreshRes = await fetch('/api/auth/refresh', { method: 'POST' }); if (!refreshRes.ok) { // Refresh failed — redirect to login window.location.href = '/login'; return refreshRes; } const { accessToken: newToken } = await refreshRes.json(); accessToken = newToken; res = await doRequest(accessToken); } return res; }
For concurrent requests that all 401 at the same time, add a queue that pauses requests while a refresh is in flight rather than firing multiple refresh calls.
On logout, two things must happen:
jti record(s) from the store so the refresh token cannot be reused.refresh_token cookie from the response.Access tokens cannot be revoked (they are stateless), which is why their lifetime must be short. If you need instant revocation—for example, after a password change—add a per-user version counter to the access token payload and check it on every request.
HttpOnly cookie, not JavaScript-accessible storage./api/auth/refresh endpoint rate-limited (see Rate Limiting Your Next.js APIs).REFRESH_TOKEN_SECRET and ACCESS_TOKEN_SECRET are different keys, each at least 256 bits.For a broader look at building secure Node.js APIs, the Securing REST APIs: Best Practices post covers authorization patterns and input validation that pair well with a solid auth layer. You can also see authentication in action in the projects section of this portfolio.
The access/refresh pattern is not complex, but it has several failure modes that each require an explicit decision: where tokens live, what happens on reuse, and how revocation works. Skipping any one of them turns a sound design into a vulnerability. Build the rotation and storage rules in from the start—retrofitting them onto an existing auth system is significantly harder.