JWT의 클레임과 서명 알고리즘을 깊이 분석하고, 리프레시 토큰 로테이션, 토큰 저장 전략, 세션 관리 패턴, BFF 패턴을 실전 코드와 함께 설명합니다.
인증이 완료되면 그 결과를 "증명서"로 남겨야 합니다. 이 증명서가 바로 토큰(Token)입니다. 토큰의 생성, 저장, 갱신, 폐기는 보안 아키텍처의 핵심이며, 잘못된 토큰 관리는 인증 시스템 전체를 무력화할 수 있습니다.
JWT(JSON Web Token, RFC 7519)는 당사자 간 안전하게 클레임을 전달하기 위한 컴팩트하고 자체 포함적인(Self-Contained) 토큰 형식입니다.
JWT는 점(.)으로 구분된 세 부분으로 구성됩니다.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. <- Header (Base64URL)
eyJzdWIiOiJ1c2VyLTEyMyIsIm5hbWUiOiLtmY3.. <- Payload (Base64URL)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c <- Signature
// Header: 알고리즘과 토큰 유형
interface JwtHeader {
alg: "RS256" | "ES256" | "EdDSA"; // 서명 알고리즘
typ: "JWT"; // 토큰 유형
kid?: string; // 서명 키 ID (키 로테이션용)
}
// Payload: 등록 클레임 + 커스텀 클레임
interface JwtPayload {
// 등록 클레임 (RFC 7519)
iss: string; // 발행자 (Issuer)
sub: string; // 주체 (Subject) - 사용자 ID
aud: string; // 수신자 (Audience)
exp: number; // 만료 시간 (UNIX timestamp)
nbf: number; // 유효 시작 시간 (Not Before)
iat: number; // 발행 시간 (Issued At)
jti: string; // 토큰 고유 ID (JWT ID)
// 커스텀 클레임
roles?: string[];
permissions?: string[];
tenant_id?: string;
}서명 알고리즘은 토큰의 무결성과 출처를 보장합니다. 대칭키와 비대칭키 두 가지 방식이 있습니다.
| 알고리즘 | 유형 | 키 크기 | 서명 속도 | 검증 속도 | 용도 |
|---|---|---|---|---|---|
| HS256 | 대칭키 (HMAC) | 256bit | 매우 빠름 | 매우 빠름 | 단일 서비스 내부 |
| RS256 | 비대칭키 (RSA) | 2048bit+ | 느림 | 빠름 | 분산 시스템 표준 |
| ES256 | 비대칭키 (ECDSA) | 256bit | 빠름 | 빠름 | 모바일/IoT |
| EdDSA | 비대칭키 (Ed25519) | 256bit | 매우 빠름 | 매우 빠름 | 최신 권장 |
HS256(대칭키)은 서명과 검증에 같은 비밀키를 사용합니다. 이는 토큰을 검증하는 모든 서비스가 비밀키를 알아야 한다는 의미이며, 비밀키 유출 시 토큰을 위조할 수 있습니다. 마이크로서비스 환경에서는 반드시 RS256, ES256, EdDSA 같은 비대칭키 알고리즘을 사용해야 합니다.
import { createRemoteJWKSet, jwtVerify } from "jose";
import type { JWTPayload, JWTVerifyResult } from "jose";
// JWKS(JSON Web Key Set) 원격 키 세트 생성
// 인가 서버의 공개키를 자동으로 가져오고 캐싱합니다
const jwks = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
);
interface VerifiedToken extends JWTPayload {
sub: string;
roles: string[];
tenant_id: string;
}
async function verifyAccessToken(
token: string,
): Promise<VerifiedToken> {
try {
const result: JWTVerifyResult = await jwtVerify(token, jwks, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["RS256", "ES256"],
// 시계 오차 허용 (초 단위)
clockTolerance: 30,
});
const payload = result.payload as VerifiedToken;
// 필수 클레임 검증
if (!payload.sub) {
throw new Error("sub 클레임이 없습니다.");
}
return payload;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("expired")) {
throw new TokenExpiredError("토큰이 만료되었습니다.");
}
if (error.message.includes("signature")) {
throw new InvalidTokenError("토큰 서명이 유효하지 않습니다.");
}
}
throw new InvalidTokenError("토큰 검증에 실패했습니다.");
}
}리프레시 토큰(Refresh Token)은 액세스 토큰이 만료되었을 때 재인증 없이 새 액세스 토큰을 발급받기 위한 토큰입니다. 리프레시 토큰 로테이션(Refresh Token Rotation)은 리프레시 토큰을 사용할 때마다 새 리프레시 토큰을 발급하고 기존 것을 폐기하는 방식입니다.
interface RefreshTokenRecord {
tokenHash: string; // 토큰 해시값
familyId: string; // 토큰 패밀리 ID
userId: string;
clientId: string;
isUsed: boolean; // 사용 여부
expiresAt: Date;
createdAt: Date;
}
async function rotateRefreshToken(
currentToken: string,
): Promise<TokenPair> {
const tokenHash = hashToken(currentToken);
const record = await findRefreshToken(tokenHash);
if (!record) {
throw new InvalidTokenError("유효하지 않은 리프레시 토큰입니다.");
}
// 이미 사용된 토큰이 다시 사용됨 = 탈취 의심
if (record.isUsed) {
// 해당 토큰 패밀리 전체 무효화
await invalidateTokenFamily(record.familyId);
throw new TokenReuseDetectedError(
"리프레시 토큰 재사용이 감지되었습니다. 모든 세션이 무효화됩니다."
);
}
// 기존 토큰을 사용됨으로 표시
await markTokenAsUsed(tokenHash);
// 새 토큰 쌍 생성
const newAccessToken = await generateAccessToken(record.userId);
const newRefreshToken = await generateRefreshToken();
// 새 리프레시 토큰 저장 (같은 패밀리)
await saveRefreshToken({
tokenHash: hashToken(newRefreshToken),
familyId: record.familyId,
userId: record.userId,
clientId: record.clientId,
isUsed: false,
expiresAt: addDays(new Date(), 30),
createdAt: new Date(),
});
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}토큰 패밀리(Token Family)는 최초 인증에서 파생된 모든 리프레시 토큰의 계보입니다. 하나의 패밀리에서 재사용이 감지되면 해당 패밀리 전체를 무효화하여, 탈취된 토큰뿐 아니라 정상 사용자의 토큰도 함께 폐기합니다. 사용자는 재인증해야 하지만, 공격자의 지속적 접근은 차단됩니다.
클라이언트에서 토큰을 어디에 저장하느냐는 보안에 직접적 영향을 미칩니다.
| 저장 위치 | XSS 방어 | CSRF 방어 | 탈취 난이도 |
|---|---|---|---|
| localStorage | 취약 | 안전 | 낮음 |
| sessionStorage | 취약 | 안전 | 중간 |
| httpOnly Cookie | 안전 | 취약 (별도 대응 필요) | 높음 |
| 메모리 (변수) | 안전 | 안전 | 매우 높음 |
import { serialize } from "cookie";
function setTokenCookies(
accessToken: string,
refreshToken: string,
): string[] {
const accessCookie = serialize("access_token", accessToken, {
httpOnly: true, // JavaScript에서 접근 불가
secure: true, // HTTPS에서만 전송
sameSite: "lax", // CSRF 기본 방어
path: "/api", // API 경로에만 전송
maxAge: 300, // 5분 (액세스 토큰 수명)
});
const refreshCookie = serialize("refresh_token", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict", // 더 엄격한 CSRF 방어
path: "/api/auth/refresh", // 갱신 엔드포인트에만 전송
maxAge: 30 * 24 * 60 * 60, // 30일
});
return [accessCookie, refreshCookie];
}SPA(Single Page Application)에서는 액세스 토큰을 JavaScript 변수(메모리)에만 보관하는 전략이 가장 안전합니다. 단, 페이지 새로고침 시 토큰이 사라지므로 리프레시 토큰(httpOnly Cookie)으로 재발급해야 합니다.
// 모듈 스코프 변수 - 클로저로 보호
let accessToken: string | null = null;
// 토큰 설정 (로그인 또는 갱신 후)
export function setAccessToken(token: string): void {
accessToken = token;
// 만료 전 자동 갱신 스케줄링
scheduleTokenRefresh(token);
}
// 토큰 가져오기 (API 요청 시)
export function getAccessToken(): string | null {
return accessToken;
}
// 토큰 삭제 (로그아웃 시)
export function clearAccessToken(): void {
accessToken = null;
}
// 만료 전 자동 갱신
function scheduleTokenRefresh(token: string): void {
const payload = parseJwtPayload(token);
const expiresIn = payload.exp * 1000 - Date.now();
// 만료 1분 전에 갱신
const refreshAt = expiresIn - 60 * 1000;
if (refreshAt > 0) {
setTimeout(async () => {
try {
const response = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include", // httpOnly 쿠키 포함
});
const data = await response.json();
setAccessToken(data.access_token);
} catch {
// 갱신 실패 시 로그아웃 처리
clearAccessToken();
window.location.href = "/login";
}
}, refreshAt);
}
}BFF(Backend For Frontend) 패턴은 프론트엔드와 인가 서버 사이에 전용 백엔드를 두어, 토큰 관리를 서버 측에서 처리하는 구조입니다. SPA의 토큰 보안 문제를 근본적으로 해결합니다.
import { Router } from "express";
import session from "express-session";
const router = Router();
// 로그인 시작 - 인가 서버로 리다이렉트
router.get("/auth/login", (req, res) => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// PKCE verifier와 state를 세션에 저장
req.session.codeVerifier = codeVerifier;
req.session.state = state;
const authUrl = buildAuthorizationUrl({
authorizationEndpoint: AUTH_SERVER_URL + "/authorize",
clientId: CLIENT_ID,
redirectUri: BFF_URL + "/auth/callback",
scopes: ["openid", "profile", "email"],
codeChallenge,
state,
});
res.redirect(authUrl);
});
// 콜백 처리 - 토큰 교환
router.get("/auth/callback", async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.state) {
return res.status(403).json({ error: "State mismatch" });
}
const tokens = await exchangeCodeForTokens({
tokenEndpoint: AUTH_SERVER_URL + "/token",
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET, // BFF는 Confidential Client
code: code as string,
redirectUri: BFF_URL + "/auth/callback",
codeVerifier: req.session.codeVerifier,
});
// 토큰을 서버 세션에 저장 (브라우저에는 전달하지 않음)
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.tokenExpiresAt = Date.now() + tokens.expires_in * 1000;
// SPA 메인 페이지로 리다이렉트
res.redirect("/");
});
// API 프록시 - 토큰을 붙여서 백엔드 API 호출
router.all("/api/*", async (req, res) => {
// 세션에서 토큰 가져오기
let accessToken = req.session.accessToken;
// 토큰 만료 체크 및 갱신
if (Date.now() >= (req.session.tokenExpiresAt ?? 0)) {
const newTokens = await refreshTokens(req.session.refreshToken);
req.session.accessToken = newTokens.access_token;
req.session.refreshToken = newTokens.refresh_token;
req.session.tokenExpiresAt =
Date.now() + newTokens.expires_in * 1000;
accessToken = newTokens.access_token;
}
// 백엔드 API로 프록시
const apiPath = req.path.replace(/^\/api/, "");
const apiResponse = await fetch(`${API_SERVER_URL}${apiPath}`, {
method: req.method,
headers: {
...req.headers,
Authorization: `Bearer ${accessToken}`,
host: new URL(API_SERVER_URL).host,
},
body: req.method !== "GET" ? JSON.stringify(req.body) : undefined,
});
res.status(apiResponse.status);
const data = await apiResponse.json();
res.json(data);
});
export default router;BFF 패턴의 핵심 이점은 토큰이 브라우저에 절대 노출되지 않는다는 것입니다. 브라우저는 세션 쿠키만 사용하며, 액세스 토큰과 리프레시 토큰은 BFF 서버의 세션 저장소에만 존재합니다. XSS 공격으로 토큰을 탈취하는 것이 원천적으로 불가능합니다.
현대적 인증 시스템에서는 순수한 스테이트리스(Stateless)보다 하이브리드 접근이 현실적입니다.
JWT는 기본적으로 만료 시간까지 유효합니다. 하지만 사용자가 로그아웃하거나 비밀번호를 변경하면 즉시 폐기해야 할 수 있습니다.
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
// 토큰 차단 목록에 추가
async function revokeToken(jti: string, expiresAt: number): Promise<void> {
const ttl = expiresAt - Math.floor(Date.now() / 1000);
if (ttl > 0) {
// 토큰의 남은 수명만큼만 차단 목록에 유지
await redis.setex(`revoked:${jti}`, ttl, "1");
}
}
// 토큰이 폐기되었는지 확인
async function isTokenRevoked(jti: string): Promise<boolean> {
const result = await redis.get(`revoked:${jti}`);
return result !== null;
}
// 미들웨어에서 사용
async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const token = extractBearerToken(req);
const payload = await verifyAccessToken(token);
// 차단 목록 확인
if (payload.jti && await isTokenRevoked(payload.jti)) {
return res.status(401).json({ error: "토큰이 폐기되었습니다." });
}
req.user = payload;
next();
}| 토큰 유형 | 권장 수명 | 근거 |
|---|---|---|
| Access Token | 5~15분 | 짧을수록 탈취 위험 감소 |
| Refresh Token | 7~30일 | 사용자 편의와 보안의 균형 |
| ID Token | 5~15분 | Access Token과 동일 수명 |
| Authorization Code | 10분 이내 | 일회성, 빠른 교환 필요 |
토큰 수명은 보안과 사용자 경험의 트레이드오프입니다. 액세스 토큰이 너무 짧으면 리프레시 요청이 빈번해지고, 너무 길면 탈취 시 피해 기간이 늘어납니다. 리프레시 토큰은 사용자가 매일 로그인하지 않아도 되도록 충분히 길게, 하지만 보안 정책이 허용하는 범위 내에서 설정합니다.
토큰 관리는 인증 시스템의 보안을 좌우하는 핵심 영역입니다. JWT의 서명 알고리즘 선택부터 저장 위치, 갱신 전략, 즉시 폐기까지, 각 결정이 전체 보안 수준에 직접적 영향을 미칩니다. BFF 패턴은 SPA 환경에서 토큰 보안의 모범 답안이며, 리프레시 토큰 로테이션은 탈취 감지의 핵심 메커니즘입니다.
다음 장에서는 "아무도 신뢰하지 않는다"는 원칙에서 출발하는 제로 트러스트 아키텍처(Zero Trust Architecture)와 인증의 관계를 살펴봅니다.
이 글이 도움이 되셨나요?
제로 트러스트의 핵심 원칙과 인증의 관계를 분석하고, 지속적 검증, 디바이스 신뢰, 컨텍스트 기반 접근 제어, BeyondCorp 모델, 아이덴티티 인식 프록시를 설명합니다.
RBAC의 한계를 분석하고, ABAC과 ReBAC(관계 기반 접근 제어)의 개념, Google Zanzibar 모델, OpenFGA와 SpiceDB의 구현 방식을 비교합니다.
API 키 관리, OAuth Client Credentials, mTLS를 활용한 서비스 간 인증, 마이크로서비스에서의 JWT 전파, API 게이트웨이 인증 패턴을 실전 코드와 함께 설명합니다.