ession stores.
- Zero DB Load: Access token validation is purely cryptographic. The database is only touched during initial login or explicit refresh token rotation.
- Security Sweet Spot: 15-minute access tokens limit exposure windows, while
httpOnly refresh tokens survive page reloads without JavaScript accessibility. The combination achieves stateless scalability without sacrificing defense-in-depth.
Core Solution
JWTs encode user claims in a tamper-evident string structured as HEADER.PAYLOAD.SIGNATURE. The header declares the signing algorithm, the payload carries minimal identifiers, and the signature is generated using a server-held secret. Verification requires no external state lookup.
Authentication Flow Architecture
- Login: Server validates credentials, issues a short-lived access token (in-memory) and a long-lived refresh token (
httpOnly cookie).
- API Requests: Frontend attaches the access token via
Authorization: Bearer <token>. Server verifies signature locally.
- Token Refresh: On access token expiry or page reload, frontend calls
/api/auth/refresh. Browser automatically sends the httpOnly cookie. Server validates it and issues a new access token.
Implementation Code
Login request (Frontend):
// Frontend
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
const { token } = await res.json()
// token is the JWT string
Server side (Node.js example):
import jwt from 'jsonwebtoken'
app.post('/api/login', async (req, res) => {
const user = await checkPassword(req.body.email, req.body.password)
if (!user) return res.status(401).send('Wrong password')
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
)
res.json({ token })
})
Using the token on every future request:
const res = await fetch('/api/profile', {
headers: { Authorization: `Bearer ${token}` },
})
Server checks the token:
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
try {
const payload = jwt.verify(token, process.env.JWT_SECRET)
// payload.userId is now trusted
res.json({ userId: payload.userId })
} catch {
res.status(401).send('Invalid token')
}
})
Secure Storage Pattern (Server sets httpOnly cookie):
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JavaScript cannot read this cookie
secure: true, // only send over HTTPS
sameSite: 'strict' // do not send on cross-site requests (blocks CSRF)
})
Access token held in memory:
let accessToken = null
export function setAccessToken(token) {
accessToken = token
}
export function getAccessToken() {
return accessToken
}
Bootstrap refresh on app startup:
// Runs on app startup
async function bootstrap() {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // sends the httpOnly cookie
})
const { accessToken } = await res.json()
setAccessToken(accessToken)
} catch {
// Not logged in, redirect to login
}
}
Complete Login Flow (Server):
app.post('/api/login', async (req, res) => {
const user = await checkPassword(req.body.email, req.body.password)
if (!user) return res.status(401).send('Wrong password')
const accessToken = jwt.sign(
{ userId: user.id },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
)
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth',
})
res.json({ accessToken })
})
Refresh Endpoint (Server):
app.post('/api/auth/refresh', (req, res) => {
const token = req.cookies.refreshToken
if (!token) return res.status(401).send('No refresh token')
try {
const payload = jwt.verify(token, process.env.REFRESH_SECRET)
const accessToken = jwt.sign(
{ userId: payload.userId },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
)
res.json({ accessToken })
} catch {
res.status(401).send('Invalid refresh token')
}
})
Frontend Axios Setup with Auto-Refresh:
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
withCredentials: true, // important: sends cookies
})
let accessToken = null
export function setAccessToken(token) {
accessToken = token
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
// If a request fails with 401, try refreshing once
api.interceptors.response.use(null, async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
original._retry = true
try {
const { data } = await api.post('/auth/refresh')
setAccessToken(data.accessToken)
return api(original) // retry the original request
} catch {
window.location.href = '/login'
}
}
return Promise.reject(error)
})
export default api
Pitfall Guide
- Storing Access Tokens in localStorage/sessionStorage: JavaScript-accessible storage is trivially exploitable via XSS. Best practice: Keep access tokens strictly in memory variables that clear on page reload.
- Using Long-Lived Access Tokens: Extending access token TTL (e.g., 24h+) maximizes the window for token replay attacks. Best practice: Limit access tokens to 5β15 minutes; rely on refresh tokens for session continuity.
- Omitting Cookie Security Flags: Failing to set
httpOnly, secure, and sameSite exposes refresh tokens to XSS and CSRF. Best practice: Always enforce httpOnly: true, secure: true (HTTPS only), and sameSite: 'strict' or 'lax'.
- Ignoring Token Revocation Mechanics: JWTs are stateless and cannot be invalidated server-side before expiry. Best practice: Implement a short-lived access token strategy, use refresh token rotation, or maintain a lightweight Redis blocklist for critical logout/compromise events.
- Over-Populating JWT Payloads: Embedding sensitive data (emails, PII, roles with fine-grained permissions) increases token size and leakage risk. Best practice: Store only minimal identifiers (
userId, sub); fetch contextual data from secure endpoints as needed.
- Hardcoding or Reusing Secrets: Using weak, predictable, or shared signing keys across environments compromises cryptographic integrity. Best practice: Generate cryptographically strong secrets per environment, store them in a secrets manager, and consider asymmetric algorithms (RS256) for multi-service architectures.
Deliverables
- π Dual-Token JWT Architecture Blueprint: Visual flow diagram mapping login, access token injection, automatic refresh cycles, and server-side verification paths. Includes microservice token validation boundaries.
- β
Security & Implementation Checklist: 18-point verification list covering cookie flags, token expiry alignment, interceptor error handling, secret rotation procedures, and XSS/CSRF mitigation validation.
- βοΈ Configuration Templates: Production-ready snippets for Express/Fastify cookie middleware, Axios/Fetch interceptors with retry logic, JWT secret management via environment variables, and Redis-based refresh token revocation setup.