JWT Authentication Guide | Access Tokens, Refresh Tokens, Security & Node.js
이 글의 핵심
JWT authentication is easy to get wrong. This guide covers the complete production pattern: short-lived access tokens, rotating refresh tokens, HttpOnly cookie storage, Redis-based revocation, and the security pitfalls that cause breaches.
JWT Structure
A JWT is three Base64URL-encoded JSON objects joined by dots: Header.Payload.Signature. The header describes the algorithm. The payload carries claims (user ID, role, expiry). The signature is a cryptographic hash of the first two parts using a secret key — it’s what prevents anyone from forging or modifying a token.
The key property that makes JWTs useful for APIs: the server doesn’t need to look up a database to validate a token. It just verifies the signature. This makes JWT-based auth stateless and horizontally scalable. The tradeoff is that tokens can’t be invalidated before they expire — which is why short expiry times and refresh token rotation matter.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEzMTY4MDAwLCJleHAiOjE3MTMxNjg5MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header.Payload.Signature
// Decode (without verifying — don't trust unverified!)
JSON.parse(atob('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'))
// Header: { alg: "HS256", typ: "JWT" }
JSON.parse(atob('eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEzMTY4MDAwLCJleHAiOjE3MTMxNjg5MDB9'))
// Payload: {
// sub: "user123", ← subject (user ID)
// role: "admin", ← custom claims
// iat: 1713168000, ← issued at
// exp: 1713168900, ← expires at (15 minutes later)
// }
The signature prevents tampering — changing any part of the payload invalidates the signature. But anyone can read the payload — don’t put secrets in it.
Setup
npm install jsonwebtoken
npm install -D @types/jsonwebtoken
Access Token + Refresh Token Pattern
Using a single long-lived JWT is the most common JWT security mistake. If that token leaks — through an XSS attack, a log file, or a browser extension — the attacker has access until it expires.
The solution is two tokens with different lifetimes. The access token is short-lived (15 minutes) and used on every API request. The refresh token is long-lived (7–30 days) and stored more carefully — it’s only sent to a dedicated /auth/refresh endpoint. When the access token expires, the client silently exchanges the refresh token for a new pair.
This limits the damage window: a stolen access token is useless in 15 minutes. A stolen refresh token can be detected and revoked (via Redis), and token rotation means a used refresh token is immediately invalidated.
// lib/tokens.ts
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
export interface TokenPayload {
sub: string; // user ID
role: string;
jti: string; // JWT ID — unique per token
}
// Short-lived access token (15 minutes)
export function createAccessToken(userId: string, role: string): string {
return jwt.sign(
{
sub: userId,
role,
jti: crypto.randomUUID(),
},
ACCESS_SECRET,
{
expiresIn: '15m',
algorithm: 'HS256',
}
);
}
// Long-lived refresh token (7 days)
export function createRefreshToken(userId: string): string {
return jwt.sign(
{
sub: userId,
jti: crypto.randomUUID(),
},
REFRESH_SECRET,
{
expiresIn: '7d',
algorithm: 'HS256',
}
);
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
}
export function verifyRefreshToken(token: string): TokenPayload {
return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
}
Login — Issue Tokens
// routes/auth.ts
import express from 'express';
import bcrypt from 'bcrypt';
import { createAccessToken, createRefreshToken } from '../lib/tokens';
import { db } from '../lib/db';
import { redis } from '../lib/redis';
const router = express.Router();
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = createAccessToken(user.id, user.role);
const refreshToken = createRefreshToken(user.id);
// Store refresh token in Redis (for revocation on logout)
const decoded = jwt.decode(refreshToken) as any;
await redis.setEx(
`refresh:${decoded.jti}`,
7 * 24 * 60 * 60, // 7 days TTL
user.id
);
// Send access token in HttpOnly cookie
res.cookie('access_token', accessToken, {
httpOnly: true, // Not accessible via JavaScript
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
});
// Send refresh token in separate HttpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/auth/refresh', // Only sent to the refresh endpoint
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.json({ user: { id: user.id, name: user.name, role: user.role } });
});
Refresh Token Rotation
Token rotation means each refresh token can only be used once. When a client exchanges a refresh token for new tokens, the old refresh token is immediately deleted from Redis. This has an important security property: if an attacker steals a refresh token and tries to use it after the legitimate client already has, Kafka detects that a token was used twice and can invalidate the session.
Without rotation, a stolen refresh token is valid for its full lifetime (days or weeks) with no way to detect the theft.
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
let payload: TokenPayload;
try {
payload = verifyRefreshToken(refreshToken);
} catch {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Check if refresh token was revoked
const userId = await redis.get(`refresh:${payload.jti}`);
if (!userId) {
return res.status(401).json({ error: 'Refresh token revoked' });
}
// Revoke the old refresh token (rotation — each refresh token used once)
await redis.del(`refresh:${payload.jti}`);
// Get fresh user data
const user = await db.user.findUnique({ where: { id: payload.sub } });
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
// Issue new token pair
const newAccessToken = createAccessToken(user.id, user.role);
const newRefreshToken = createRefreshToken(user.id);
// Store new refresh token
const newDecoded = jwt.decode(newRefreshToken) as any;
await redis.setEx(`refresh:${newDecoded.jti}`, 7 * 24 * 60 * 60, user.id);
// Set new cookies
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ user: { id: user.id, name: user.name, role: user.role } });
});
Logout — Revoke Tokens
router.post('/logout', authenticate, async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (refreshToken) {
try {
const payload = verifyRefreshToken(refreshToken);
await redis.del(`refresh:${payload.jti}`); // Revoke refresh token
} catch {
// Token already invalid — that's fine
}
}
// Clear cookies
res.clearCookie('access_token');
res.clearCookie('refresh_token', { path: '/auth/refresh' });
res.json({ message: 'Logged out successfully' });
});
Auth Middleware
// middleware/authenticate.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../lib/tokens';
export interface AuthRequest extends Request {
user?: { id: string; role: string };
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const token = req.cookies.access_token;
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const payload = verifyAccessToken(token);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Role-based authorization
export function authorize(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage
router.get('/admin/users', authenticate, authorize('admin'), listUsers);
router.get('/profile', authenticate, getProfile);
Client-Side Token Refresh
// api/client.ts — automatically refresh on 401
const API_URL = process.env.NEXT_PUBLIC_API_URL;
async function apiCall(path: string, options: RequestInit = {}): Promise<Response> {
let response = await fetch(`${API_URL}${path}`, {
...options,
credentials: 'include', // Send cookies with cross-origin requests
});
// If access token expired, refresh and retry once
if (response.status === 401) {
const body = await response.json();
if (body.code === 'TOKEN_EXPIRED') {
const refreshed = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (refreshed.ok) {
// Retry original request with new token (in cookie)
response = await fetch(`${API_URL}${path}`, {
...options,
credentials: 'include',
});
} else {
// Refresh failed — redirect to login
window.location.href = '/login';
}
}
}
return response;
}
Security Checklist
Token storage:
✅ HttpOnly cookies (not localStorage)
✅ Secure flag (HTTPS only)
✅ SameSite=Strict or Lax (CSRF protection)
Token configuration:
✅ Short expiry for access tokens (15 min)
✅ Refresh token rotation (invalidate on use)
✅ Different secrets for access and refresh tokens
✅ Include jti claim for revocation
Secrets:
✅ Strong secrets (256+ bits of entropy)
✅ Stored in environment variables, not code
✅ Different secrets per environment
Common mistakes:
❌ Storing tokens in localStorage (XSS vulnerable)
❌ Long-lived access tokens (hours/days)
❌ No refresh token rotation
❌ Putting sensitive data in payload (it's readable)
❌ Using 'none' algorithm
❌ Not validating exp and iat claims
❌ Sharing JWT secrets across services
Related posts: