JWT Authentication Guide | Access Tokens· Refresh Tokens
이 글의 핵심
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.
Deep dive: JWS, verification, and production claims
A JWT in the wild is almost always a JWS compact serialization (RFC 7515): three Base64URL segments. The middle segment is not encrypted ??only Base64URL ??so treat it as public. The third segment is the signature over the exact bytes base64url(header) + "." + base64url(payload) using the algorithm named in alg.
Signature verification (step by step)
- Split the token; require exactly two dots and three parts.
- Parse header JSON; allowlist
alg(rejectnoneand unexpected algorithms). - For HS256, recompute HMAC-SHA256 with your secret and compare in constant time. For RS256, verify with the IdP?�s public key (often fetched via JWKS and cached by
kid). - Parse payload JSON only after signature success.
- Enforce
exp(andnbf/iatif present). - Enforce
audandisswhen the token is issued by an external IdP or shared across services.
Claims you should validate
Beyond exp, production APIs often require iss (issuer URL), aud (your API identifier), and optionally jti for revocation or rotation. Custom claims like role drive authorization after authentication.
Refresh strategies in production
Pair short-lived access tokens with long-lived refresh tokens stored server-side or in HttpOnly cookies. Use refresh token rotation: each refresh consumes the old refresh token and issues a new pair; detect reuse (same refresh token submitted twice) and revoke the whole session. Store refresh jti in Redis with TTL aligned to token expiry.
Patterns
Separate signing keys for access vs refresh; use RS256 when many services verify tokens; publish JWKS for key rotation; never trust decode alone for authorization decisions.
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??0 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, the server detects that a token was reused 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:
- [FastAPI Complete Guide](/en/blog/fastapi-complete-guide/
- [GraphQL Complete Guide](/en/blog/graphql-complete-guide/
- [Redis Complete Guide](/en/blog/redis-advanced-guide/
?�주 묻는 질문 (FAQ)
Q. ???�용???�무?�서 ?�제 ?�나??
A. Implement secure JWT authentication in Node.js. Covers JWT structure, access/refresh token patterns, HttpOnly cookies vs???�무?�서????본문???�제?� ?�택 가?�드�?참고???�용?�면 ?�니??
Q. ?�행?�로 ?�으�?좋�? 글?�?
A. �?글 ?�단???�전 글 ?�는 관??글 링크�??�라가�??�서?��?배울 ???�습?�다. C++ ?�리�?목차?�서 ?�체 ?�름???�인?????�습?�다.
Q. ??깊이 공�??�려�?
A. cppreference?� ?�당 ?�이브러�?공식 문서�?참고?�세?? 글 말�???참고 ?�료 링크???�용?�면 좋습?�다.
같이 보면 좋�? 글 (?��? 링크)
??주제?� ?�결?�는 ?�른 글?�니??
- [FastAPI ?�벽 가?�드 ??Python 최고 ?�능 ???�레?�워?? Django·Flask ?��?(/blog/fastapi-complete-guide/)
- GraphQL ?�벽 가?�드: API 쿼리 ?�어
- [Redis 고급 가?�드 | 캐싱·Pub/Sub·Streams·?�러?�터·?�능 최적??(/blog/redis-advanced-guide/)
??글?�서 ?�루???�워??(관??검?�어)
JWT, Authentication, Security, Node.js, TypeScript, Backend, API ?�으�?검?�하?�면 ??글???��????�니??