JWT (JSON Web Token) is everywhere — but it's also frequently misimplemented in ways that create real security vulnerabilities. Understanding how JWTs work and where the pitfalls are is essential for any developer building authenticated applications.
A JWT is a base64-encoded string with three parts separated by dots:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJleHAiOjE3MDAwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decode the payload and you get:
{
"userId": "123",
"email": "user@example.com",
"exp": 1700000000
}The critical point: JWTs are not encrypted by default — they're just signed. Anyone who has the token can read the payload. Never put passwords, credit card numbers, or sensitive data in a JWT.
// lib/jwt.ts
import jwt from 'jsonwebtoken'
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!
export interface TokenPayload {
userId: string
email: string
role: string
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_ACCESS_SECRET, {
expiresIn: '15m', // Short-lived — 15 minutes
issuer: 'myapp',
audience: 'myapp-users',
})
}
export function generateRefreshToken(userId: string): string {
return jwt.sign({ userId }, JWT_REFRESH_SECRET, {
expiresIn: '7d', // Long-lived — 7 days
})
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, JWT_ACCESS_SECRET, {
issuer: 'myapp',
audience: 'myapp-users',
}) as TokenPayload
}
export function verifyRefreshToken(token: string): { userId: string } {
return jwt.verify(token, JWT_REFRESH_SECRET) as { userId: string }
}Never issue a single long-lived token. Use short-lived access tokens with long-lived refresh tokens:
// controllers/auth.controller.ts
export async function login(req: Request, res: Response) {
const { email, password } = req.body
const user = await db.user.findUnique({ where: { email } })
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const accessToken = generateAccessToken({
userId: user.id,
email: user.email,
role: user.role,
})
const refreshToken = generateRefreshToken(user.id)
// Store refresh token in DB so we can invalidate it
await db.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
})
// Send refresh token as HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
})
// Send access token in response body
res.json({ accessToken, user: { id: user.id, email: user.email, role: user.role } })
}// Refresh endpoint — get a new access token using the refresh token
export async function refresh(req: Request, res: Response) {
const refreshToken = req.cookies.refreshToken
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' })
try {
const { userId } = verifyRefreshToken(refreshToken)
// Verify token exists in DB (allows revocation)
const stored = await db.refreshToken.findFirst({
where: { token: refreshToken, userId, expiresAt: { gt: new Date() } },
include: { user: true },
})
if (!stored) return res.status(401).json({ error: 'Invalid refresh token' })
const accessToken = generateAccessToken({
userId: stored.user.id,
email: stored.user.email,
role: stored.user.role,
})
res.json({ accessToken })
} catch {
res.status(401).json({ error: 'Invalid refresh token' })
}
}// middleware/auth.ts
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' })
}
const token = header.slice(7)
try {
req.user = verifyAccessToken(token)
next()
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' })
}
res.status(401).json({ error: 'Invalid token' })
}
}// lib/apiClient.ts
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
// Attach access token to every request
api.interceptors.request.use(config => {
const token = localStorage.getItem('accessToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
let isRefreshing = false
let failedQueue: Array<{ resolve: Function; reject: Function }> = []
// Automatically refresh expired tokens
api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config
if (error.response?.status === 401 &&
error.response?.data?.code === 'TOKEN_EXPIRED' &&
!originalRequest._retry) {
if (isRefreshing) {
// Queue requests while refresh is in progress
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true })
localStorage.setItem('accessToken', data.accessToken)
failedQueue.forEach(p => p.resolve(data.accessToken))
failedQueue = []
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
return api(originalRequest)
} catch (refreshError) {
failedQueue.forEach(p => p.reject(refreshError))
failedQueue = []
localStorage.removeItem('accessToken')
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
export default api| Storage | XSS Risk | CSRF Risk | Notes |
|---|---|---|---|
localStorage | HIGH | None | Accessible to any JS on the page |
sessionStorage | HIGH | None | Same as localStorage but clears on tab close |
| HTTP-only Cookie | None | Moderate | Can't be read by JS; use SameSite=strict to mitigate CSRF |
| Memory only | None | None | Lost on page refresh — use for access tokens |
Best practice: Store access tokens in memory (React state), refresh tokens in HTTP-only cookies. If you must persist the access token, use a short expiry (15 min) and accept the tradeoff.
1. Long-lived access tokens. If a token is stolen, the attacker has access until it expires. Keep access tokens short (15 min max).
2. Not storing refresh tokens in the database. Without DB storage, you can't revoke a compromised refresh token.
3. Using the same secret for access and refresh tokens. A leaked access token secret shouldn't also compromise refresh tokens.
4. Putting sensitive data in the payload. JWTs are base64-encoded, not encrypted. Anyone who intercepts the token can read it.
5. Not verifying the alg header. Some old libraries had a vulnerability where setting alg: none bypassed signature verification. Use a maintained library.