안녕하세요! JWT 인증 시리즈의 네 번째 글입니다. 1편에서는 JWT의 기본 개념과, 2편에서는 Node.js/Express를 활용한 서버 구현을, 3편에서는 프론트엔드에서의 JWT 구현에 대해 알아보았습니다. 이번 4편에서는 JWT 보안을 강화하고 토큰 탈취를 방지하는 다양한 방법과 모범 사례에 대해 자세히 알아보겠습니다.
목차
- JWT 보안의 중요성
- JWT 취약점 이해하기
- 토큰 저장 전략과 보안
- 토큰 무효화 전략
- 토큰 수명 최적화
- JWT 탈취 방지 기술
- 리프레시 토큰 보안
- CSRF & XSS 공격 방어
- 블랙리스트와 화이트리스트 전략
- JWT 서명 알고리즘 강화
- 고급 JWT 보안 기술
- 마무리
JWT 보안의 중요성
JWT(JSON Web Token)는 간단하고 강력한 인증 메커니즘이지만, 부적절하게 구현될 경우 심각한 보안 위험을 초래할 수 있습니다. JWT가 탈취되면 공격자는 토큰이 만료되기 전까지 토큰 소유자로 위장할 수 있으며, 이로 인해 개인 정보 유출, 권한 없는 리소스 접근, 데이터 손상 등의 문제가 발생할 수 있습니다.
JWT 보안의 중요성을 보여주는 주요 통계:
- 2022년 보안 연구에 따르면, 웹 애플리케이션 공격의 약 25%가 인증 메커니즘을 대상으로 합니다.
- 토큰 기반 인증 시스템의 취약점을 이용한 데이터 침해 사례는 매년 23% 증가하고 있습니다.
- 부적절한 JWT 구현으로 인한 보안 사고의 76%는 토큰 저장 및 전송 방식의 취약점과 관련이 있습니다.
이러한 위험을 최소화하기 위해 JWT 구현 시 보안 모범 사례를 따르는 것이 중요합니다.
JWT 취약점 이해하기
JWT와 관련된 주요 보안 취약점을 이해하는 것은 효과적인 방어 전략을 수립하는 첫 단계입니다.
1. 알고리즘 변경 공격(Algorithm Switching)
JWT는 헤더에 서명 알고리즘 정보를 포함하고 있습니다. 공격자는 이 정보를 수정하여 서명 검증 방식을 변경할 수 있습니다.
취약한 예시:
// 알고리즘을 검증하지 않는 취약한 코드
const verifyToken = (token) => {
try {
const decoded = jwt.verify(token, secretKey);
return decoded;
} catch (error) {
return null;
}
};
보안 개선 예시:
// 알고리즘을 명시적으로 지정하여 공격 방지
const verifyToken = (token: string): UserPayload | null => {
try {
const decoded = jwt.verify(token, secretKey, {
algorithms: ['HS256'] // 허용할 알고리즘 명시적 지정
}) as UserPayload;
return decoded;
} catch (error) {
return null;
}
};
2. 토큰 탈취
웹 스토리지, 안전하지 않은 쿠키, 또는 네트워크 전송 중 토큰이 탈취될 수 있습니다.
취약점 예시:
- localStorage/sessionStorage에 토큰 저장(XSS 공격에 취약)
- 안전하지 않은 쿠키 설정(HttpOnly, Secure 플래그 없음)
- HTTPS 없이 토큰 전송
3. 토큰 재사용 공격
탈취된 토큰이 원래 사용자와 공격자에 의해 동시에 사용될 수 있습니다.
4. None 알고리즘 공격
일부 JWT 라이브러리는 'none' 알고리즘을 허용하며, 이를 통해 공격자는 서명 검증 없이 토큰을 조작할 수 있습니다.
토큰 저장 전략과 보안
토큰 저장 위치는 JWT 보안에 중요한 영향을 미칩니다. 각 저장 방식의 보안 특성을 비교해 보겠습니다.
1. 웹 스토리지(localStorage/sessionStorage)
보안 위험:
- XSS(Cross-Site Scripting) 공격에 매우 취약
- 공격자가 JavaScript를 통해 쉽게 액세스 가능
보안 개선 방법:
// 🚨 보안 위험: 권장하지 않음
localStorage.setItem('accessToken', token);
// 대안: XSS 방어와 함께 사용 (여전히 위험)
import DOMPurify from 'dompurify';
// XSS 방어를 위한 입력 정화
const sanitizeInput = (input: string) => {
return DOMPurify.sanitize(input);
};
// 사용자 입력을 받는 모든 곳에서 정화 처리
const userInput = sanitizeInput(document.getElementById('input').value);
document.getElementById('output').innerHTML = userInput;
2. 쿠키 기반 저장
보안 특성:
- HttpOnly 플래그로 JavaScript 접근 차단(XSS 방어)
- Secure 플래그로 HTTPS 연결에서만 전송
- SameSite 속성으로 CSRF 공격 방어
- 자동으로 모든 요청에 포함됨
보안 설정 예시:
// 서버 측 쿠키 설정 (Express.js)
res.cookie('refreshToken', token, {
httpOnly: true, // JavaScript에서 접근 불가
secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송
sameSite: 'strict', // 동일 사이트 요청에만 쿠키 전송
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
path: '/' // 모든 경로에서 액세스 가능
});
// Next.js API 라우트에서의 쿠키 설정
import { serialize } from 'cookie';
export default function handler(req, res) {
// 쿠키 설정
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7일
path: '/'
};
res.setHeader('Set-Cookie', serialize('refreshToken', token, cookieOptions));
res.status(200).json({ success: true });
}
3. 메모리 저장 전략
보안 특성:
- 페이지 새로고침 시 데이터 손실
- 브라우저 탭 간 공유 불가능
- JavaScript 변수로만 저장되어 영구 저장소에 접근하지 않음
구현 예시:
// 메모리 기반 토큰 저장소
let memoryToken: string | null = null;
export const tokenService = {
setToken: (token: string) => {
memoryToken = token;
},
getToken: () => memoryToken,
removeToken: () => {
memoryToken = null;
}
};
// API 요청에 토큰 포함
import axios from 'axios';
import { tokenService } from './tokenService';
const api = axios.create({
baseURL: '/api'
});
api.interceptors.request.use(config => {
const token = tokenService.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
4. 하이브리드 접근법(권장)
액세스 토큰과 리프레시 토큰을 다른 방식으로 저장하는 하이브리드 접근법이 현재 가장 권장되는 방식입니다.
구현 예시:
// 액세스 토큰: 메모리에 저장 (짧은 만료 시간)
let accessToken: string | null = null;
// 리프레시 토큰: HttpOnly 쿠키로 저장 (서버에서 설정)
export const authService = {
// 로그인 함수
login: async (credentials: { email: string; password: string }) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
credentials: 'include' // 쿠키 포함
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
// 액세스 토큰은 메모리에 저장
accessToken = data.accessToken;
return data.user;
},
// 토큰 가져오기
getAccessToken: () => accessToken,
// 로그아웃
logout: async () => {
// 액세스 토큰 메모리에서 제거
accessToken = null;
// 리프레시 토큰 쿠키 제거 요청
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
},
// 토큰 새로고침
refreshToken: async () => {
try {
// 리프레시 토큰은 쿠키에 저장되어 있으므로 별도로 보내지 않음
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const data = await response.json();
// 새 액세스 토큰 메모리에 저장
accessToken = data.accessToken;
return true;
} catch (error) {
accessToken = null;
return false;
}
}
};
토큰 무효화 전략
JWT는 기본적으로 발급 후 서버에서 직접 무효화할 수 없습니다. 하지만 다음과 같은 전략을 통해 토큰 무효화를 구현할 수 있습니다.
1. 블랙리스트 방식
만료되지 않은 토큰을 무효화하기 위해 Redis와 같은 인메모리 데이터베이스를 사용한 블랙리스트 구현:
// 서버 측 구현 (Node.js)
import { createClient } from 'redis';
// Redis 클라이언트 생성
const redisClient = createClient({
url: process.env.REDIS_URL
});
redisClient.connect().catch(console.error);
// 토큰 블랙리스트에 추가 (로그아웃 시)
export const blacklistToken = async (token: string, exp: number) => {
const now = Math.floor(Date.now() / 1000);
const ttl = exp - now; // 남은 만료 시간(초)
if (ttl <= 0) return; // 이미 만료된 토큰은 블랙리스트에 추가하지 않음
// 토큰 해시를 블랙리스트에 추가 (전체 토큰 저장 대신 보안 강화)
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await redisClient.set(`bl:${tokenHash}`, '1', { EX: ttl });
};
// 토큰이 블랙리스트에 있는지 확인
export const isTokenBlacklisted = async (token: string) => {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await redisClient.get(`bl:${tokenHash}`);
return result !== null;
};
// 토큰 검증 미들웨어에 블랙리스트 확인 추가
export const verifyToken = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
try {
// 블랙리스트 확인
const blacklisted = await isTokenBlacklisted(token);
if (blacklisted) {
return res.status(401).json({ message: '만료된 토큰입니다.' });
}
// 토큰 검증
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
};
2. 토큰 버전 관리
사용자별 토큰 버전을 관리하여 특정 버전 이전의 모든 토큰을 무효화하는 방식:
// 사용자 모델에 tokenVersion 필드 추가
interface User {
id: number;
email: string;
tokenVersion: number;
// 기타 필드
}
// 토큰 생성 시 토큰 버전 포함
const generateToken = (user: User) => {
return jwt.sign(
{
id: user.id,
email: user.email,
tokenVersion: user.tokenVersion
},
JWT_SECRET,
{ expiresIn: '15m' }
);
};
// 로그아웃 시 토큰 버전 증가
const logout = async (userId: number) => {
// 데이터베이스에서 사용자 찾기
const user = await findUserById(userId);
if (user) {
// 토큰 버전 증가
user.tokenVersion += 1;
// 사용자 정보 업데이트
await updateUser(user);
}
};
// 토큰 검증 시 버전 확인
const verifyTokenVersion = async (token: string) => {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number; tokenVersion: number };
// 데이터베이스에서 현재 사용자 버전 조회
const user = await findUserById(decoded.id);
// 토큰의 버전이 사용자의 현재 버전과 일치하는지 확인
if (!user || decoded.tokenVersion !== user.tokenVersion) {
throw new Error('Token version mismatch');
}
return decoded;
} catch (error) {
return null;
}
};
3. 짧은 만료 시간 설정
액세스 토큰의 만료 시간을 짧게 설정하고 리프레시 토큰을 사용하는 방식:
// 액세스 토큰 생성 (짧은 만료 시간)
const generateAccessToken = (user: User) => {
return jwt.sign(
{ id: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' } // 15분 만료
);
};
// 리프레시 토큰 생성 (긴 만료 시간)
const generateRefreshToken = (user: User) => {
return jwt.sign(
{ id: user.id, tokenVersion: user.tokenVersion },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' } // 7일 만료
);
};
토큰 수명 최적화
토큰 만료 시간은 보안과 사용자 경험 사이의 균형을 고려하여 설정해야 합니다.
1. 권장 토큰 수명 설정:
- 액세스 토큰: 5분 ~ 15분 (보안이 중요한 경우 더 짧게)
- 리프레시 토큰: 1일 ~ 30일 (필요에 따라 조정)
2. 컨텍스트별 토큰 수명 조정:
// 컨텍스트에 따른 토큰 수명 조정
const generateContextAwareToken = (user: User, context: {
deviceType: 'mobile' | 'desktop' | 'public',
riskScore: number
}) => {
// 기본 만료 시간 (15분)
let expiresIn = '15m';
// 디바이스 유형에 따른 조정
if (context.deviceType === 'mobile') {
expiresIn = '30m'; // 모바일은 더 안전하다고 가정
} else if (context.deviceType === 'public') {
expiresIn = '5m'; // 공용 디바이스는 보안 강화
}
// 위험 점수에 따른 추가 조정 (0-100 범위)
if (context.riskScore > 70) {
expiresIn = '5m'; // 위험 높음
} else if (context.riskScore < 20) {
expiresIn = '1h'; // 위험 낮음
}
return jwt.sign(
{ id: user.id, role: user.role },
JWT_SECRET,
{ expiresIn }
);
};
3. 사용자 활동 기반 토큰 갱신:
// 클라이언트 측에서 토큰 갱신 관리
const TOKEN_REFRESH_THRESHOLD = 5 * 60; // 만료 5분 전에 갱신
// 토큰의 만료 시간 파악
const getTokenExpiration = (token: string): number => {
try {
const decoded = jwt_decode<{ exp: number }>(token);
return decoded.exp;
} catch (error) {
return 0;
}
};
// 사용자 활동 감지와 토큰 갱신
const setupTokenRefresh = () => {
// 토큰 상태 확인 함수
const checkTokenStatus = async () => {
const token = tokenService.getAccessToken();
if (!token) return;
const expTime = getTokenExpiration(token);
const currentTime = Math.floor(Date.now() / 1000);
const timeLeft = expTime - currentTime;
// 만료 임계값보다 적게 남았다면 토큰 갱신
if (timeLeft > 0 && timeLeft < TOKEN_REFRESH_THRESHOLD) {
await tokenService.refreshToken();
}
};
// 사용자 활동 이벤트에 토큰 상태 확인 연결
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
document.addEventListener(event, checkTokenStatus, { passive: true });
});
// 주기적으로도 확인
setInterval(checkTokenStatus, 60 * 1000); // 1분마다
};
JWT 탈취 방지 기술
토큰이 탈취되더라도 악용을 최소화하기 위한 여러 기술을 살펴보겠습니다.
1. 토큰 바인딩 (Token Binding)
토큰을 특정 요소(디바이스 지문, IP 등)에 바인딩하여 다른 환경에서 사용을 방지합니다.
// 클라이언트 지문 생성 (디바이스 정보 수집)
import FingerprintJS from '@fingerprintjs/fingerprintjs';
// 디바이스 지문 가져오기
const getDeviceFingerprint = async () => {
const fp = await FingerprintJS.load();
const result = await fp.get();
return result.visitorId;
};
// 서버에서 토큰 발급 시 지문 추가
const generateBoundToken = async (user: User, fingerprint: string) => {
return jwt.sign(
{
id: user.id,
role: user.role,
fingerprint: crypto.createHash('sha256').update(fingerprint).digest('hex')
},
JWT_SECRET,
{ expiresIn: '15m' }
);
};
// 클라이언트 측 로그인 로직
const login = async (email: string, password: string) => {
// 디바이스 지문 가져오기
const fingerprint = await getDeviceFingerprint();
// 로그인 요청에 지문 포함
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, fingerprint })
});
const data = await response.json();
return data;
};
// 서버 측 토큰 검증 로직
const verifyBoundToken = async (token: string, req: Request) => {
try {
// 토큰 디코딩
const decoded = jwt.verify(token, JWT_SECRET) as {
id: number;
fingerprint: string;
};
// 요청에서 지문 가져오기
const fingerprint = req.headers['x-device-fingerprint'] as string;
if (!fingerprint) {
throw new Error('Fingerprint missing');
}
// 지문 해시 생성
const fingerprintHash = crypto
.createHash('sha256')
.update(fingerprint)
.digest('hex');
// 토큰의 지문과 비교
if (decoded.fingerprint !== fingerprintHash) {
throw new Error('Fingerprint mismatch');
}
return decoded;
} catch (error) {
return null;
}
};
2. 추가 클레임 포함
위치 정보, 디바이스 ID 등 컨텍스트 정보를 토큰에 포함하여 보안을 강화합니다.
// 클라이언트에서 컨텍스트 정보 수집
const getContextInfo = async () => {
// 위치 정보 가져오기 (허용된 경우)
let locationInfo = null;
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
locationInfo = {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy
};
} catch (error) {
console.log('Location access denied or unavailable');
}
// 디바이스 및 네트워크 정보
const deviceInfo = {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
screenSize: `${window.screen.width}x${window.screen.height}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
connection: navigator.connection ? {
effectiveType: (navigator.connection as any).effectiveType,
downlink: (navigator.connection as any).downlink
} : null
};
return {
locationInfo,
deviceInfo
};
};
// 서버에서 컨텍스트 정보를 토큰에 포함
const generateContextAwareToken = (user: User, context: any) => {
// 컨텍스트 정보 요약 해시 생성
const contextHash = crypto
.createHash('sha256')
.update(JSON.stringify(context))
.digest('hex');
return jwt.sign(
{
id: user.id,
role: user.role,
context: {
hash: contextHash,
ip: context.ip, // 요청 IP
userAgent: context.userAgent.substring(0, 50), // 요약 정보만 포함
geo: context.locationInfo ? {
area: getGeoArea(context.locationInfo.lat, context.locationInfo.lng)
} : null
}
},
JWT_SECRET,
{ expiresIn: '15m' }
);
};
// 토큰 검증 시 컨텍스트 정보 확인
const verifyContextAwareToken = (token: string, req: Request) => {
try {
const decoded = jwt.verify(token, JWT_SECRET) as {
id: number;
context: {
ip: string;
userAgent: string;
geo?: { area: string };
};
};
// IP 검증 (완전히 일치하지 않을 수 있으므로 유연하게 처리)
const ipChanged = !isSimilarIP(decoded.context.ip, req.ip);
// 사용자 에이전트 검증 (브라우저 업데이트 등으로 약간 변경될 수 있음)
const userAgentChanged = !req.headers['user-agent']?.includes(decoded.context.userAgent);
// 위치 정보 검증 (가능한 경우)
let geoChanged = false;
if (decoded.context.geo && req.headers['x-geo-location']) {
const currentGeo = JSON.parse(req.headers['x-geo-location'] as string);
geoChanged = decoded.context.geo.area !== getGeoArea(currentGeo.lat, currentGeo.lng);
}
// 변화 점수 계산
let suspicionScore = 0;
if (ipChanged) suspicionScore += 0.5;
if (userAgentChanged) suspicionScore += 0.3;
if (geoChanged) suspicionScore += 0.7;
// 점수가 임계값을 초과하면 추가 검증 필요
if (suspicionScore >= 0.8) {
throw new Error('Context verification failed');
}
return decoded;
} catch (error) {
return null;
}
};
3. 동적 토큰 사용 (One-Time Tokens)
각 요청마다 새로운 토큰을 사용하는 전략으로, 토큰이 탈취되더라도 재사용이 불가능합니다.
// 서버 측: 토큰 체인 생성
const generateTokenChain = (user: User, chainLength = 5) => {
// 마지막 토큰부터 시작하여 체인 생성
let currentToken = crypto.randomBytes(32).toString('hex');
const tokens = [currentToken];
// 이전 토큰의 해시가 다음 토큰이 되는 체인 생성
for (let i = 1; i < chainLength; i++) {
currentToken = crypto
.createHash('sha256')
.update(currentToken)
.digest('hex');
tokens.unshift(currentToken);
}
// 사용자 정보와 함께 첫 번째 토큰 반환
const rootToken = tokens[0];
// 나머지 토큰은 서버에 저장
saveTokenChain(user.id, tokens.slice(1));
return jwt.sign(
{ id: user.id, role: user.role, chainIndex: 0 },
rootToken, // 토큰 자체를 시크릿 키로 사용
{ expiresIn: '1h' }
);
};
// 클라이언트 측: 토큰 사용 및 갱신
const useNextToken = async (currentToken) => {
// 현재 토큰을 사용하여 API 호출
const response = await fetch('/api/secure-data', {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
// 응답에서 다음 토큰 가져오기
const nextToken = response.headers.get('X-Next-Token');
if (nextToken) {
// 새 토큰 저장
tokenService.setToken(nextToken);
}
return response;
};
// 서버 측: 요청 처리 및 새 토큰 발급
const handleSecureRequest = async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
try {
// 토큰 검증
const decoded = jwt.verify(token, JWT_SECRET) as {
id: number;
chainIndex: number;
};
// 사용자 검증
const user = await findUserById(decoded.id);
if (!user) {
throw new Error('User not found');
}
// 다음 토큰 가져오기
const nextToken = await getNextTokenInChain(user.id, decoded.chainIndex + 1);
// 응답 헤더에 다음 토큰 포함
res.setHeader('X-Next-Token', nextToken);
// 요청 처리
// ...
return res.status(200).json({ data: '보안 데이터' });
} catch (error) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
};
4. 이벤트 기반 토큰 모니터링 (Event-Based Token Monitoring)
토큰 사용 패턴을 모니터링하여 이상 징후를 감지하고 대응합니다.
// 토큰 사용 로깅
const logTokenUsage = async (userId: number, tokenId: string, context: any) => {
await db.tokenUsageLogs.create({
userId,
tokenId,
timestamp: new Date(),
ip: context.ip,
userAgent: context.userAgent,
endpoint: context.endpoint,
geoLocation: context.geoLocation
});
};
// 비정상적인 토큰 사용 감지
const detectAbnormalTokenUsage = async (userId: number) => {
// 최근 로그 가져오기
const recentLogs = await db.tokenUsageLogs.findMany({
where: { userId },
orderBy: { timestamp: 'desc' },
take: 20
});
if (recentLogs.length < 5) return { suspicious: false };
// 이상 징후 분석
const uniqueIPs = new Set(recentLogs.map(log => log.ip)).size;
const timeSpread = (recentLogs[0].timestamp.getTime() - recentLogs[recentLogs.length - 1].timestamp.getTime()) / (1000 * 60); // 분 단위
const requestFrequency = recentLogs.length / timeSpread; // 분당 요청 수
// 이상 징후 점수 계산
let suspicionScore = 0;
// 다양한 IP에서 접근
if (uniqueIPs > 3) suspicionScore += uniqueIPs * 0.2;
// 비정상적인 요청 빈도
if (requestFrequency > 20) suspicionScore += requestFrequency * 0.05;
// 지리적 이동 속도 확인
for (let i = 1; i < recentLogs.length; i++) {
const prev = recentLogs[i-1];
const curr = recentLogs[i];
if (prev.geoLocation && curr.geoLocation) {
const distance = calculateGeoDistance(prev.geoLocation, curr.geoLocation); // km
const timeDiff = (prev.timestamp.getTime() - curr.timestamp.getTime()) / (1000 * 60 * 60); // 시간
const speed = distance / timeDiff; // km/h
// 물리적으로 불가능한 이동 속도 (예: 500 km/h 이상)
if (speed > 500) {
suspicionScore += 5;
}
}
}
return {
suspicious: suspicionScore > 5,
suspicionScore,
details: {
uniqueIPs,
requestFrequency,
timeSpread
}
};
};
// 토큰 검증 미들웨어에 이상 징후 감지 통합
const enhancedTokenVerification = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
try {
// 기본 토큰 검증
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
// 토큰 사용 로깅
const tokenId = decoded.jti || crypto.createHash('md5').update(token).digest('hex');
await logTokenUsage(decoded.id, tokenId, {
ip: req.ip,
userAgent: req.headers['user-agent'],
endpoint: req.path,
geoLocation: req.headers['x-geo-location'] ? JSON.parse(req.headers['x-geo-location']) : null
});
// 이상 징후 감지
const abnormalUsage = await detectAbnormalTokenUsage(decoded.id);
if (abnormalUsage.suspicious) {
// 이상 징후가 감지되면 추가 검증 요구
return res.status(403).json({
message: '추가 인증이 필요합니다.',
requireAdditionalAuth: true,
reason: 'suspicious_activity'
});
}
next();
} catch (error) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
};
리프레시 토큰 보안
리프레시 토큰은 수명이 긴 만큼 더 강력한 보안이 필요합니다.
1. 리프레시 토큰 순환 (Rotation)
리프레시 토큰을 사용할 때마다 새로운 리프레시 토큰을 발급하여 토큰 재사용을 방지합니다.
// 리프레시 토큰 사용 시 새 토큰 발급
const refreshTokens = async (refreshToken: string) => {
try {
// 토큰 검증
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as {
id: number;
family: string; // 토큰 패밀리 ID
version: number; // 토큰 버전
};
// 데이터베이스에서 최신 토큰 정보 조회
const tokenRecord = await db.refreshTokens.findUnique({
where: {
userId_family: {
userId: decoded.id,
family: decoded.family
}
}
});
// 토큰 기록이 없거나 버전이 맞지 않으면 거부 (토큰 재사용 시도)
if (!tokenRecord || tokenRecord.version !== decoded.version) {
// 토큰 재사용 감지 - 모든 패밀리 토큰 무효화
await db.refreshTokens.deleteMany({
where: { userId: decoded.id, family: decoded.family }
});
throw new Error('Refresh token reuse detected');
}
// 사용자 정보 조회
const user = await findUserById(decoded.id);
if (!user) {
throw new Error('User not found');
}
// 새 액세스 토큰 생성
const accessToken = generateAccessToken(user);
// 새 리프레시 토큰 생성 (버전 증가)
const newVersion = tokenRecord.version + 1;
const newRefreshToken = jwt.sign(
{
id: user.id,
family: decoded.family,
version: newVersion
},
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// 데이터베이스 업데이트
await db.refreshTokens.update({
where: {
userId_family: {
userId: user.id,
family: decoded.family
}
},
data: {
token: hashToken(newRefreshToken), // 해시된 토큰만 저장
version: newVersion,
lastUsed: new Date()
}
});
return {
accessToken,
refreshToken: newRefreshToken
};
} catch (error) {
throw new Error(`Token refresh failed: ${error.message}`);
}
};
// 토큰 해싱 함수
const hashToken = (token: string): string => {
return crypto.createHash('sha256').update(token).digest('hex');
};
2. 리프레시 토큰 패밀리 관리
여러 디바이스에서 로그인한 경우를 효과적으로 관리하는 패밀리 기반 토큰 관리:
// 로그인 시 새 토큰 패밀리 생성
const createTokenFamily = async (user: User, deviceInfo: any) => {
// 패밀리 ID 생성
const familyId = crypto.randomBytes(16).toString('hex');
// 액세스 토큰 생성
const accessToken = generateAccessToken(user);
// 리프레시 토큰 생성
const refreshToken = jwt.sign(
{
id: user.id,
family: familyId,
version: 1
},
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// 데이터베이스에 토큰 정보 저장
await db.refreshTokens.create({
data: {
userId: user.id,
family: familyId,
token: hashToken(refreshToken),
version: 1,
deviceInfo: JSON.stringify(deviceInfo),
createdAt: new Date(),
lastUsed: new Date()
}
});
return {
accessToken,
refreshToken
};
};
// 특정 디바이스 로그아웃 (단일 토큰 패밀리 무효화)
const logoutDevice = async (userId: number, familyId: string) => {
await db.refreshTokens.delete({
where: {
userId_family: {
userId,
family: familyId
}
}
});
};
// 모든 디바이스 로그아웃 (전체 토큰 패밀리 무효화)
const logoutAllDevices = async (userId: number) => {
await db.refreshTokens.deleteMany({
where: { userId }
});
};
// 활성 세션 조회
const getActiveSessions = async (userId: number) => {
const tokenRecords = await db.refreshTokens.findMany({
where: { userId }
});
return tokenRecords.map(record => ({
family: record.family,
deviceInfo: JSON.parse(record.deviceInfo),
createdAt: record.createdAt,
lastUsed: record.lastUsed
}));
};
3. 리프레시 토큰 악용 감지
리프레시 토큰의 비정상적인 사용 패턴을 감지하고 대응합니다:
// 리프레시 토큰 사용 감지 및 대응
const detectRefreshTokenAbuse = async (userId: number) => {
// 최근 리프레시 토큰 사용 기록 조회
const recentRefreshes = await db.tokenUsageLogs.findMany({
where: {
userId,
operation: 'REFRESH_TOKEN'
},
orderBy: { timestamp: 'desc' },
take: 10
});
if (recentRefreshes.length < 3) return false;
// 빠른 연속 리프레시 감지 (예: 10초 이내에 3회 이상)
const now = new Date();
const recentCount = recentRefreshes.filter(log => {
const timeDiff = now.getTime() - log.timestamp.getTime();
return timeDiff < 10 * 1000; // 10초
}).length;
if (recentCount >= 3) {
// 잠재적 공격 감지 - 모든 리프레시 토큰 무효화
await logoutAllDevices(userId);
// 보안 알림 발송
await sendSecurityAlert(userId, '비정상적인 토큰 갱신 시도가 감지되었습니다.');
return true;
}
return false;
};
CSRF & XSS 공격 방어
1. CSRF(Cross-Site Request Forgery) 방어
CSRF 공격은 사용자가 인증된 상태에서 악의적인 사이트가 사용자 모르게 요청을 보내는 공격입니다.
// CSRF 토큰 생성 (서버 측)
const generateCsrfToken = (userId: number): string => {
// 랜덤 토큰 생성
const token = crypto.randomBytes(32).toString('hex');
// Redis나 세션에 저장 (사용자 ID와 연결)
redisClient.set(`csrf:${userId}:${token}`, '1', { EX: 3600 }); // 1시간 유효
return token;
};
// Express 미들웨어로 CSRF 보호 구현
const csrfProtection = async (req, res, next) => {
// GET 요청은 건너뜀
if (req.method === 'GET') {
return next();
}
// 사용자 ID
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ message: '인증이 필요합니다.' });
}
// CSRF 토큰 확인
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken) {
return res.status(403).json({ message: 'CSRF 토큰이 필요합니다.' });
}
// 토큰 유효성 확인
const isValid = await redisClient.get(`csrf:${userId}:${csrfToken}`);
if (!isValid) {
return res.status(403).json({ message: '유효하지 않은 CSRF 토큰입니다.' });
}
// 토큰 사용 후 제거 (일회용)
await redisClient.del(`csrf:${userId}:${csrfToken}`);
next();
};
// 클라이언트 측에서 CSRF 토큰 관리
const setupCsrfProtection = () => {
let csrfToken = null;
// CSRF 토큰 가져오기
const fetchCsrfToken = async () => {
const response = await fetch('/api/auth/csrf-token');
const data = await response.json();
csrfToken = data.token;
};
// API 호출 시 CSRF 토큰 포함
const api = axios.create({
baseURL: '/api'
});
api.interceptors.request.use(async config => {
// GET 요청이 아니면 CSRF 토큰 포함
if (config.method !== 'get') {
// 토큰이 없으면 가져오기
if (!csrfToken) {
await fetchCsrfToken();
}
config.headers['X-CSRF-Token'] = csrfToken;
// 토큰 사용 후 새 토큰 가져오기
fetchCsrfToken().catch(console.error);
}
return config;
});
return api;
};
2. XSS(Cross-Site Scripting) 방어
XSS 공격은 웹 사이트에 악성 스크립트를 삽입하여 사용자 세션을 탈취하는 공격입니다.
// 서버 측 응답 헤더 설정
app.use((req, res, next) => {
// XSS 방어를 위한 Content-Security-Policy 설정
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' https://trusted-cdn.com; img-src 'self' data: https://trusted-cdn.com;"
);
// XSS 필터 활성화
res.setHeader('X-XSS-Protection', '1; mode=block');
// 콘텐츠 타입 스니핑 방지
res.setHeader('X-Content-Type-Options', 'nosniff');
// 클릭재킹 방지
res.setHeader('X-Frame-Options', 'DENY');
next();
});
// 클라이언트 측 입력 검증 및 정화
import DOMPurify from 'dompurify';
// 사용자 입력 정화 함수
const sanitizeInput = (input: string): string => {
return DOMPurify.sanitize(input);
};
// React 컴포넌트에서 사용 예시
const CommentForm = () => {
const [comment, setComment] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
// 입력 정화 후 API 호출
const sanitizedComment = sanitizeInput(comment);
await api.post('/comments', { content: sanitizedComment });
setComment('');
};
return (
<form onSubmit={handleSubmit}>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="댓글을 입력하세요"
/>
<button type="submit">등록</button>
</form>
);
};
블랙리스트와 화이트리스트 전략
JWT는 상태를 저장하지 않는(stateless) 특성을 가지고 있지만, 보안을 강화하기 위해 토큰 관리 전략을 구현할 수 있습니다.
1. 토큰 블랙리스트
로그아웃한 토큰이나 탈취된 토큰을 블랙리스트에 추가하여 사용을 방지합니다.
// Redis를 사용한 토큰 블랙리스트 구현
import { createClient } from 'redis';
const redisClient = createClient({
url: process.env.REDIS_URL
});
redisClient.connect().catch(console.error);
// 토큰을 블랙리스트에 추가
const blacklistToken = async (token: string) => {
try {
// 토큰 디코딩하여 만료 시간 확인
const decoded = jwt.decode(token) as { exp: number };
if (!decoded || !decoded.exp) {
throw new Error('Invalid token format');
}
const now = Math.floor(Date.now() / 1000);
const ttl = Math.max(0, decoded.exp - now); // 남은 시간(초)
// 토큰 해시를 블랙리스트에 추가
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await redisClient.set(`bl:${tokenHash}`, '1', { EX: ttl });
return true;
} catch (error) {
console.error('블랙리스트 추가 실패:', error);
return false;
}
};
// 토큰이 블랙리스트에 있는지 확인
const isTokenBlacklisted = async (token: string): Promise<boolean> => {
try {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await redisClient.get(`bl:${tokenHash}`);
return result !== null;
} catch (error) {
console.error('블랙리스트 확인 실패:', error);
return false; // 오류 발생 시 기본적으로 블랙리스트에 없다고 간주
}
};
// 로그아웃 API 구현
app.post('/api/auth/logout', authenticateToken, async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
// 토큰을 블랙리스트에 추가
await blacklistToken(token);
}
// 리프레시 토큰 쿠키 제거
res.clearCookie('refreshToken');
res.status(200).json({ message: '로그아웃 성공' });
} catch (error) {
res.status(500).json({ message: '로그아웃 중 오류가 발생했습니다.' });
}
});
// 토큰 검증 미들웨어에 블랙리스트 확인 추가
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
try {
// 블랙리스트 확인
const blacklisted = await isTokenBlacklisted(token);
if (blacklisted) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
// 토큰 검증
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
};
블랙리스트 방식의 장단점:
장점:
- 필요할 때 즉시 토큰을 무효화할 수 있습니다.
- 토큰 탈취가 의심될 때 빠르게 대응할 수 있습니다.
단점:
- 캐시 서버(Redis 등)가 필요하여 인프라 복잡성이 증가합니다.
- 상태 저장이 필요하여 JWT의 상태 비저장(stateless) 장점이 일부 상실됩니다.
- 모든 요청마다 블랙리스트 확인이 필요하여 성능에 영향을 줄 수 있습니다.
2. 토큰 화이트리스트
허용된 토큰만 유효하게 인정하는 화이트리스트 방식입니다.
// Redis를 사용한 토큰 화이트리스트 구현
// 사용자 로그인 시 토큰을 화이트리스트에 추가
const whitelistToken = async (userId: number, token: string, deviceInfo: any) => {
try {
// 토큰 디코딩하여 만료 시간 확인
const decoded = jwt.decode(token) as { exp: number; jti: string };
if (!decoded || !decoded.exp || !decoded.jti) {
throw new Error('Invalid token format');
}
const now = Math.floor(Date.now() / 1000);
const ttl = Math.max(0, decoded.exp - now); // 남은 시간(초)
// 토큰 정보를 화이트리스트에 추가
await redisClient.set(
`wl:${userId}:${decoded.jti}`,
JSON.stringify({
device: deviceInfo.name,
ip: deviceInfo.ip,
createdAt: new Date().toISOString()
}),
{ EX: ttl }
);
return true;
} catch (error) {
console.error('화이트리스트 추가 실패:', error);
return false;
}
};
// 토큰이 화이트리스트에 있는지 확인
const isTokenWhitelisted = async (userId: number, tokenId: string): Promise<boolean> => {
try {
const result = await redisClient.get(`wl:${userId}:${tokenId}`);
return result !== null;
} catch (error) {
console.error('화이트리스트 확인 실패:', error);
return false;
}
};
// 로그인 API 구현 (화이트리스트 적용)
app.post('/api/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// 사용자 인증
const user = await authenticateUser(email, password);
if (!user) {
return res.status(401).json({ message: '이메일 또는 비밀번호가 잘못되었습니다.' });
}
// 고유 토큰 ID 생성
const tokenId = crypto.randomBytes(16).toString('hex');
// 토큰 생성 (토큰 ID 포함)
const accessToken = jwt.sign(
{ id: user.id, role: user.role, jti: tokenId },
JWT_SECRET,
{ expiresIn: '15m' }
);
// 디바이스 정보 수집
const deviceInfo = {
name: req.headers['user-agent'] || 'unknown',
ip: req.ip
};
// 토큰을 화이트리스트에 추가
await whitelistToken(user.id, accessToken, deviceInfo);
// 리프레시 토큰 생성 및 쿠키 설정
const refreshToken = generateRefreshToken(user);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7일
});
res.status(200).json({
accessToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (error) {
res.status(500).json({ message: '로그인 중 오류가 발생했습니다.' });
}
});
// 토큰 검증 미들웨어에 화이트리스트 확인 추가
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
try {
// 토큰 검증
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as {
id: number;
jti: string;
};
// 화이트리스트 확인
const whitelisted = await isTokenWhitelisted(decoded.id, decoded.jti);
if (!whitelisted) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
};
화이트리스트 방식의 장단점:
장점:
- 모든 유효한 토큰을 명시적으로 관리할 수 있습니다.
- 특정 사용자의 모든 세션을 쉽게 관리할 수 있습니다.
- 토큰 발급 및 사용에 대한 상세한 모니터링이 가능합니다.
단점:
- 모든 토큰에 대한 저장소가 필요하여 리소스 사용량이 증가합니다.
- JWT의 상태 비저장(stateless) 특성이 완전히 상실됩니다.
- 매 요청마다 데이터베이스 조회가 필요하여 성능 저하가 발생할 수 있습니다.
3. 하이브리드 접근법
블랙리스트와 화이트리스트의 장점을 결합한 하이브리드 접근법입니다.
// 리프레시 토큰은 화이트리스트에 저장
// 액세스 토큰은 필요한 경우만 블랙리스트에 추가
// 리프레시 토큰 화이트리스트에 추가
const whitelistRefreshToken = async (userId: number, token: string, deviceInfo: any) => {
try {
// 토큰 해시 생성
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// 이전 토큰 정보 가져오기
const previousTokens = await redisClient.lRange(`wl:refresh:${userId}`, 0, -1);
// 최대 디바이스 수 제한 (예: 5개)
if (previousTokens.length >= 5) {
// 가장 오래된 토큰 제거
const oldestToken = await redisClient.lPop(`wl:refresh:${userId}`);
if (oldestToken) {
await redisClient.del(`wl:refresh:data:${oldestToken}`);
}
}
// 토큰 정보 저장
await redisClient.set(
`wl:refresh:data:${tokenHash}`,
JSON.stringify({
userId,
device: deviceInfo.name,
ip: deviceInfo.ip,
createdAt: new Date().toISOString()
}),
{ EX: 60 * 60 * 24 * 7 } // 7일
);
// 사용자의 토큰 리스트에 추가
await redisClient.rPush(`wl:refresh:${userId}`, tokenHash);
await redisClient.expire(`wl:refresh:${userId}`, 60 * 60 * 24 * 30); // 30일
return true;
} catch (error) {
console.error('리프레시 토큰 화이트리스트 추가 실패:', error);
return false;
}
};
// 리프레시 토큰이 화이트리스트에 있는지 확인
const isRefreshTokenWhitelisted = async (token: string): Promise<any> => {
try {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const result = await redisClient.get(`wl:refresh:data:${tokenHash}`);
if (!result) return null;
return JSON.parse(result);
} catch (error) {
console.error('리프레시 토큰 화이트리스트 확인 실패:', error);
return null;
}
};
// 보안 이슈가 있는 경우에만 액세스 토큰 블랙리스트에 추가
const blacklistAccessToken = async (token: string) => {
try {
// 토큰 디코딩
const decoded = jwt.decode(token) as { exp: number };
if (!decoded || !decoded.exp) {
throw new Error('Invalid token format');
}
const now = Math.floor(Date.now() / 1000);
const ttl = Math.max(0, decoded.exp - now); // 남은 시간(초)
// 만료까지 5분 미만인 경우 블랙리스트에 추가하지 않음
if (ttl < 300) {
return true; // 곧 만료될 토큰은 블랙리스트에 추가할 필요 없음
}
// 토큰 해시를 블랙리스트에 추가
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await redisClient.set(`bl:${tokenHash}`, '1', { EX: ttl });
return true;
} catch (error) {
console.error('블랙리스트 추가 실패:', error);
return false;
}
};
// 토큰 갱신 구현
app.post('/api/auth/refresh', async (req, res) => {
try {
// 쿠키에서 리프레시 토큰 추출
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: '리프레시 토큰이 필요합니다.' });
}
// 리프레시 토큰이 화이트리스트에 있는지 확인
const tokenData = await isRefreshTokenWhitelisted(refreshToken);
if (!tokenData) {
return res.status(401).json({ message: '유효하지 않은 리프레시 토큰입니다.' });
}
// 리프레시 토큰 검증
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as { id: number };
// 토큰 데이터와 디코딩된 사용자 ID 일치 확인
if (tokenData.userId !== decoded.id) {
return res.status(401).json({ message: '토큰 정보가 일치하지 않습니다.' });
}
// 사용자 정보 조회
const user = await findUserById(decoded.id);
if (!user) {
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
}
// 새 액세스 토큰 생성
const accessToken = jwt.sign(
{ id: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);
// 디바이스 정보 업데이트
const deviceInfo = {
name: req.headers['user-agent'] || 'unknown',
ip: req.ip
};
// 리프레시 토큰 정보 업데이트 (마지막 사용 시간)
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await redisClient.set(
`wl:refresh:data:${tokenHash}`,
JSON.stringify({
...tokenData,
lastUsed: new Date().toISOString(),
ip: deviceInfo.ip // IP 업데이트
}),
{ EX: 60 * 60 * 24 * 7 } // 7일
);
res.status(200).json({ accessToken });
} catch (error) {
res.status(401).json({ message: '토큰 갱신에 실패했습니다.' });
}
});
// 보안 이슈 발생 시 모든 토큰 무효화
const invalidateAllTokens = async (userId: number) => {
try {
// 1. 모든 리프레시 토큰 조회
const tokenHashes = await redisClient.lRange(`wl:refresh:${userId}`, 0, -1);
// 2. 각 리프레시 토큰 데이터 삭제
for (const hash of tokenHashes) {
await redisClient.del(`wl:refresh:data:${hash}`);
}
// 3. 리프레시 토큰 리스트 삭제
await redisClient.del(`wl:refresh:${userId}`);
// 4. 사용자 버전 증가 (선택적)
await updateUserTokenVersion(userId);
return true;
} catch (error) {
console.error('토큰 무효화 실패:', error);
return false;
}
};
하이브리드 접근법의 장점:
- 리프레시 토큰은 화이트리스트로 엄격하게 관리하여 장기적인 보안 강화
- 액세스 토큰은 짧은 수명으로 유지하고 필요한 경우에만 블랙리스트에 추가하여 성능 최적화
- 사용자별 활성 세션 관리 기능 제공
- 보안 이슈 발생 시 효과적인 대응 가능
JWT 서명 알고리즘 강화
JWT 보안에서 가장 중요한 요소 중 하나는 적절한 서명 알고리즘 선택입니다.
1. 대칭키 vs 비대칭키 알고리즘
대칭키 알고리즘 (HMAC):
- 같은 비밀 키로 서명과 검증을 모두 수행
- 구현이 간단하고 처리 속도가 빠름
- 모든 서버가 같은 비밀 키를 공유해야 함
// 대칭키 알고리즘 (HMAC) 사용 예시
const generateHmacToken = (payload: any): string => {
return jwt.sign(payload, SECRET_KEY, {
algorithm: 'HS256', // HMAC + SHA256
expiresIn: '15m'
});
};
const verifyHmacToken = (token: string): any => {
return jwt.verify(token, SECRET_KEY, {
algorithms: ['HS256'] // 허용할 알고리즘 명시적 지정
});
};
비대칭키 알고리즘 (RSA/ECDSA):
- 공개 키로 검증하고 개인 키로 서명
- 개인 키는 인증 서버에만 저장하고 공개 키는 리소스 서버에 배포 가능
- 마이크로서비스 아키텍처에 적합
- 키 관리가 복잡하고 처리 속도가 상대적으로 느림
// 비대칭키 알고리즘 (RSA) 사용 예시
import { readFileSync } from 'fs';
// 키 로드
const privateKey = readFileSync('private_key.pem');
const publicKey = readFileSync('public_key.pem');
const generateRsaToken = (payload: any): string => {
return jwt.sign(payload, privateKey, {
algorithm: 'RS256', // RSA + SHA256
expiresIn: '15m'
});
};
const verifyRsaToken = (token: string): any => {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'] // 허용할 알고리즘 명시적 지정
});
};
2. 키 순환(Key Rotation) 구현
보안 강화를 위해 주기적으로 서명 키를 변경하는 전략입니다.
// 키 관리 서비스 구현
class KeyManager {
private keys: Map<string, { key: string; expiresAt: Date }> = new Map();
private currentKeyId: string;
constructor() {
// 초기 키 생성
this.rotateKeys();
}
// 새 키 생성 및 교체
rotateKeys() {
const keyId = crypto.randomBytes(8).toString('hex');
const key = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30일 후
this.keys.set(keyId, { key, expiresAt });
this.currentKeyId = keyId;
// 만료된 키 제거
for (const [id, keyData] of this.keys.entries()) {
if (keyData.expiresAt < new Date()) {
this.keys.delete(id);
}
}
// 30일마다 자동 교체
setTimeout(() => this.rotateKeys(), 30 * 24 * 60 * 60 * 1000);
return { keyId, key };
}
// 현재 활성 키 가져오기
getCurrentKey() {
const keyData = this.keys.get(this.currentKeyId);
return { keyId: this.currentKeyId, key: keyData?.key };
}
// 특정 ID의 키 가져오기
getKey(keyId: string) {
return this.keys.get(keyId)?.key;
}
}
// 키 관리자 인스턴스 생성
const keyManager = new KeyManager();
// 키 순환을 적용한 토큰 생성
const generateTokenWithKeyRotation = (payload: any): string => {
const { keyId, key } = keyManager.getCurrentKey();
const token = jwt.sign(
{ ...payload, kid: keyId }, // Key ID를 페이로드에 포함
key,
{ algorithm: 'HS256', expiresIn: '15m' }
);
return token;
};
// 키 순환을 적용한 토큰 검증
const verifyTokenWithKeyRotation = (token: string): any => {
// 토큰에서 헤더 추출
const segments = token.split('.');
if (segments.length !== 3) {
throw new Error('Invalid token format');
}
// 헤더 디코딩
const header = JSON.parse(Buffer.from(segments[0], 'base64').toString());
// 키 ID 확인
const keyId = header.kid;
if (!keyId) {
throw new Error('Key ID is missing');
}
// 키 가져오기
const key = keyManager.getKey(keyId);
if (!key) {
throw new Error('Invalid Key ID');
}
// 토큰 검증
return jwt.verify(token, key, { algorithms: ['HS256'] });
};
3. EdDSA 알고리즘 활용
최신 암호화 알고리즘인 EdDSA(Edwards-curve Digital Signature Algorithm)는 RSA보다 더 작은 키 크기로 동등한 보안을 제공합니다.
// EdDSA 알고리즘 사용
import crypto from 'crypto';
// 키 쌍 생성
const generateKeyPair = () => {
return crypto.generateKeyPairSync('ed25519', {
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
};
const { privateKey, publicKey } = generateKeyPair();
// EdDSA를 사용한 JWT 생성
const generateEdDSAToken = (payload: any): string => {
return jwt.sign(payload, privateKey, {
algorithm: 'EdDSA',
expiresIn: '15m'
});
};
// EdDSA를 사용한 JWT 검증
const verifyEdDSAToken = (token: string): any => {
return jwt.verify(token, publicKey, {
algorithms: ['EdDSA']
});
};
고급 JWT 보안 기술
1. 이중 토큰 검증
중요한 작업(비밀번호 변경, 결제, 개인정보 수정 등)에 대해서는 이중 토큰 검증을 구현하여 보안을 강화합니다.
// 중요 작업용 특수 토큰 생성
const generateCriticalActionToken = (userId: number, action: string): string => {
return jwt.sign(
{
sub: userId,
action,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 10 * 60 // 10분 유효
},
CRITICAL_ACTION_SECRET
);
};
// 중요 작업 토큰 요청 API
app.post('/api/auth/action-token', authenticateToken, async (req, res) => {
try {
const { action } = req.body;
const userId = req.user.id;
// 허용된 액션 목록
const allowedActions = ['change-password', 'update-email', 'payment', 'delete-account'];
if (!allowedActions.includes(action)) {
return res.status(400).json({ message: '유효하지 않은 액션입니다.' });
}
// 특수 토큰 생성
const actionToken = generateCriticalActionToken(userId, action);
// 선택적: 이메일이나 SMS로 토큰 사용 알림 발송
await sendSecurityAlert(userId, `중요 작업 "${action}" 수행을 위한 토큰이 발급되었습니다.`);
res.status(200).json({ actionToken });
} catch (error) {
res.status(500).json({ message: '토큰 생성 중 오류가 발생했습니다.' });
}
});
// 중요 작업 수행 API (이중 토큰 검증)
app.post('/api/users/change-password', authenticateToken, async (req, res) => {
try {
const { currentPassword, newPassword, actionToken } = req.body;
const userId = req.user.id;
// 1. 액션 토큰 검증
if (!actionToken) {
return res.status(403).json({ message: '중요 작업 토큰이 필요합니다.' });
}
try {
const decoded = jwt.verify(actionToken, CRITICAL_ACTION_SECRET) as {
sub: number;
action: string;
};
// 토큰의 사용자 ID와 현재 사용자 ID 일치 확인
if (decoded.sub !== userId) {
throw new Error('User ID mismatch');
}
// 토큰의 액션 검증
if (decoded.action !== 'change-password') {
throw new Error('Invalid action');
}
} catch (error) {
return res.status(403).json({ message: '유효하지 않은 작업 토큰입니다.' });
}
// 2. 비밀번호 변경 로직
const user = await findUserById(userId);
// 현재 비밀번호 확인
const passwordValid = await bcrypt.compare(currentPassword, user.password);
if (!passwordValid) {
return res.status(400).json({ message: '현재 비밀번호가 일치하지 않습니다.' });
}
// 새 비밀번호 해시
const hashedPassword = await bcrypt.hash(newPassword, 10);
// 비밀번호 업데이트
await updateUserPassword(userId, hashedPassword);
// 모든 리프레시 토큰 무효화
await invalidateAllRefreshTokens(userId);
res.status(200).json({ message: '비밀번호가 성공적으로 변경되었습니다.' });
} catch (error) {
res.status(500).json({ message: '비밀번호 변경 중 오류가 발생했습니다.' });
}
});
2. JWT 수직 및 수평 확장(Vertical and Horizontal Scaling)
많은 요청을 처리해야 하는 대규모 시스템에서의 JWT 활용 전략입니다.
// Redis를 사용한 JWT 캐싱 구현
import { createClient } from 'redis';
// Redis 클라이언트 생성
const redisClient = createClient({
url: process.env.REDIS_URL
});
redisClient.connect().catch(console.error);
// 블랙리스트 캐싱용 프리픽스
const BLACKLIST_PREFIX = 'bl:';
// 사용자 정보 캐싱용 프리픽스
const USER_PREFIX = 'user:';
// 토큰 검증 미들웨어 (캐싱 적용)
const authenticateToken = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
try {
// 1. 블랙리스트 캐시 확인
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const blacklisted = await redisClient.get(`${BLACKLIST_PREFIX}${tokenHash}`);
if (blacklisted) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
// 2. 토큰 검증
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as {
id: number;
role: string;
};
// 3. 사용자 정보 캐시 확인
const cachedUser = await redisClient.get(`${USER_PREFIX}${decoded.id}`);
if (cachedUser) {
// 캐시된 사용자 정보 사용
req.user = JSON.parse(cachedUser);
} else {
// DB에서 사용자 정보 조회
const user = await findUserById(decoded.id);
if (!user) {
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
}
// 사용자 정보 캐싱 (민감한 정보 제외)
const userToCache = {
id: user.id,
name: user.name,
email: user.email,
role: user.role
};
await redisClient.set(
`${USER_PREFIX}${decoded.id}`,
JSON.stringify(userToCache),
{ EX: 60 * 60 } // 1시간 캐싱
);
req.user = userToCache;
}
next();
} catch (error) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
};
// 토큰 생성 시 부하 분산
const generateLoadBalancedToken = (user: User) => {
// 서버 인스턴스마다 다른 시크릿 키를 사용하는 대신
// 공유 시크릿 키 사용 (모든 서버가 검증 가능)
return jwt.sign(
{ id: user.id, role: user.role },
SHARED_JWT_SECRET,
{ expiresIn: '15m' }
);
};
3. 트래픽 서명(Traffic Signing)
API 요청/응답에 추가적인 서명을 포함하여 중간자 공격을 방지합니다.
// 클라이언트 측 요청 서명
const signRequest = (method: string, url: string, body: any, timestamp: number, nonce: string) => {
// 서명할 데이터 준비
const dataToSign = `${method.toUpperCase()}:${url}:${JSON.stringify(body)}:${timestamp}:${nonce}`;
// HMAC-SHA256으로 서명
const signature = crypto
.createHmac('sha256', CLIENT_SECRET)
.update(dataToSign)
.digest('hex');
return signature;
};
// 요청에 서명 추가
const api = axios.create({
baseURL: '/api'
});
api.interceptors.request.use(config => {
const nonce = crypto.randomBytes(16).toString('hex');
const timestamp = Date.now();
// 요청 서명 생성
const signature = signRequest(
config.method || 'get',
config.url || '',
config.data,
timestamp,
nonce
);
// 헤더에 서명 정보 추가
config.headers['X-Request-Signature'] = signature;
config.headers['X-Request-Timestamp'] = timestamp;
config.headers['X-Request-Nonce'] = nonce;
return config;
});
// 서버 측 요청 서명 검증
const verifyRequestSignature = (req, res, next) => {
const signature = req.headers['x-request-signature'];
const timestamp = parseInt(req.headers['x-request-timestamp']);
const nonce = req.headers['x-request-nonce'];
// 필수 헤더 확인
if (!signature || !timestamp || !nonce) {
return res.status(401).json({ message: '서명이 필요합니다.' });
}
// 타임스탬프 검증 (5분 이내)
const now = Date.now();
if (now - timestamp > 5 * 60 * 1000) {
return res.status(401).json({ message: '만료된 요청입니다.' });
}
// 서명할 데이터 준비
const dataToSign = `${req.method.toUpperCase()}:${req.path}:${JSON.stringify(req.body)}:${timestamp}:${nonce}`;
// HMAC-SHA256으로 서명 검증
const expectedSignature = crypto
.createHmac('sha256', CLIENT_SECRET)
.update(dataToSign)
.digest('hex');
// 서명 일치 확인
if (signature !== expectedSignature) {
return res.status(401).json({ message: '유효하지 않은 서명입니다.' });
}
// 논스(nonce) 재사용 방지
const nonceKey = `nonce:${nonce}`;
redisClient.get(nonceKey).then(exists => {
if (exists) {
return res.status(401).json({ message: '이미 사용된 논스입니다.' });
}
// 논스 사용 기록 (1시간 동안 유지)
redisClient.set(nonceKey, '1', { EX: 60 * 60 });
next();
}).catch(error => {
console.error('논스 확인 중 오류:', error);
return res.status(500).json({ message: '서버 오류가 발생했습니다.' });
});
};
4. 분산 환경에서의 JWT 관리
마이크로서비스 아키텍처에서 JWT를 효과적으로 관리하는 전략입니다.
// 공개/개인 키 쌍 사용
import { readFileSync } from 'fs';
import { JWK, JWS } from 'node-jose';
// 인증 서비스에서만 사용하는 개인 키
const privateKey = readFileSync('private_key.pem');
// 모든 서비스에서 공유하는 공개 키
const publicKey = readFileSync('public_key.pem');
// 키스토어 생성
const keystore = JWK.createKeyStore();
// RSA 키 가져오기
const setupKeys = async () => {
await keystore.add(privateKey, 'pem');
};
// 토큰 발급 (인증 서비스에서만 수행)
const generateServiceToken = async (payload: any) => {
const key = keystore.all({ use: 'sig' })[0];
// JWS(JSON Web Signature) 생성
const signed = await JWS.createSign({ format: 'compact' }, key)
.update(JSON.stringify({
...payload,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 15 * 60 // 15분
}))
.final();
return signed;
};
// 토큰 검증 (모든 서비스에서 수행 가능)
const verifyServiceToken = async (token: string) => {
try {
const result = await JWS.createVerify()
.use(publicKey)
.verify(token);
const payload = JSON.parse(result.payload.toString());
// 만료 시간 확인
const now = Math.floor(Date.now() / 1000);
if (payload.exp <= now) {
throw new Error('Token expired');
}
return payload;
} catch (error) {
throw new Error(`Token verification failed: ${error.message}`);
}
};
// 서비스 간 인증 미들웨어
const authenticateServiceRequest = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
try {
const payload = await verifyServiceToken(token);
req.serviceContext = payload;
next();
} catch (error) {
return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
};
5. JWT 요청 제한 및 스로틀링
토큰 기반으로 API 요청 횟수를 제한하여 무차별 대입 공격을 방지합니다.
// Redis를 사용한 요청 제한 구현
import { RateLimiterRedis } from 'rate-limiter-flexible';
// Redis 클라이언트
const redisClient = createClient({
url: process.env.REDIS_URL
});
redisClient.connect().catch(console.error);
// 일반 사용자용 레이트 리미터
const userRateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'ratelimit:user',
points: 100, // 허용 요청 수
duration: 60, // 60초 동안
blockDuration: 60 * 10 // 제한 초과 시 10분 차단
});
// 관리자용 레이트 리미터 (더 높은 제한)
const adminRateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'ratelimit:admin',
points: 500, // 더 많은 요청 허용
duration: 60
});
// 토큰 기반 요청 제한 미들웨어
const rateLimitByToken = async (req, res, next) => {
try {
// 토큰에서 사용자 정보 추출
const user = req.user;
if (!user) {
return next(); // 인증되지 않은 요청은 다른 미들웨어에서 처리
}
// 사용자 역할에 따라 적절한 레이트 리미터 선택
const limiter = user.role === 'admin' ? adminRateLimiter : userRateLimiter;
// 사용자 ID 기반으로 제한 적용
await limiter.consume(user.id.toString());
next();
} catch (error) {
// 요청 제한 초과
if (error.remainingPoints !== undefined) {
return res.status(429).json({
message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.',
retryAfter: Math.round(error.msBeforeNext / 1000) || 1
});
}
// 다른 오류
console.error('레이트 리밋 오류:', error);
next(error);
}
};
// 라우트에 미들웨어 적용 예시
app.post('/api/sensitive-operation',
authenticateToken, // 먼저 토큰 검증
rateLimitByToken, // 그 다음 요청 제한 적용
async (req, res) => {
// 비즈니스 로직
}
);
6. Proof of Possession(PoP) 토큰
클라이언트가 토큰을 합법적으로 소유하고 있음을 증명하는 방식입니다.
// PoP 토큰 발급
const issuePopToken = async (userId: number) => {
// 일회용 키 쌍 생성 (클라이언트에서 생성해야 하지만, 예시를 위해 서버에서 생성)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
// 공개 키 해시 (토큰의 고유 식별자로 사용)
const keyThumbprint = crypto
.createHash('sha256')
.update(publicKey)
.digest('base64url');
// PoP 토큰 생성
const popToken = jwt.sign(
{
sub: userId,
cnf: {
jwk: {
kty: 'RSA',
use: 'sig',
kid: keyThumbprint
}
}
},
JWT_SECRET,
{ expiresIn: '1h' }
);
return {
popToken,
privateKey,
keyThumbprint
};
};
// 클라이언트 측: PoP 증명 생성
const createPopProof = (url: string, method: string, privateKey: string, keyThumbprint: string) => {
// 타임스탬프와 논스
const timestamp = Date.now();
const nonce = crypto.randomBytes(16).toString('hex');
// 서명할 데이터
const dataToSign = `${method.toUpperCase()}:${url}:${timestamp}:${nonce}`;
// 개인 키로 서명
const signature = crypto.sign(
'sha256',
Buffer.from(dataToSign),
privateKey
).toString('base64');
return {
kid: keyThumbprint,
timestamp,
nonce,
signature
};
};
// 서버 측: PoP 증명 검증
const verifyPopProof = async (req, res, next) => {
try {
// 필수 헤더 확인
const popToken = req.headers['x-pop-token'];
const popProof = req.headers['x-pop-proof'];
if (!popToken || !popProof) {
return res.status(401).json({ message: 'PoP 증명이 필요합니다.' });
}
// PoP 토큰 검증
const decodedToken = jwt.verify(popToken, JWT_SECRET) as {
sub: number;
cnf: {
jwk: {
kid: string;
}
}
};
// PoP 증명 파싱
const proof = JSON.parse(popProof);
// 토큰과 증명의 kid 일치 확인
if (decodedToken.cnf.jwk.kid !== proof.kid) {
throw new Error('Key ID mismatch');
}
// 논스 중복 사용 확인
const nonceKey = `nonce:${proof.nonce}`;
const nonceExists = await redisClient.get(nonceKey);
if (nonceExists) {
throw new Error('Nonce already used');
}
// 타임스탬프 검증 (5분 이내)
const now = Date.now();
if (now - proof.timestamp > 5 * 60 * 1000) {
throw new Error('Timestamp expired');
}
// 공개 키 조회 (실제로는 DB나 키 저장소에서 조회)
const publicKey = await getPublicKeyByThumbprint(proof.kid);
// 서명할 데이터 재구성
const dataToVerify = `${req.method.toUpperCase()}:${req.path}:${proof.timestamp}:${proof.nonce}`;
// 서명 검증
const isValid = crypto.verify(
'sha256',
Buffer.from(dataToVerify),
publicKey,
Buffer.from(proof.signature, 'base64')
);
if (!isValid) {
throw new Error('Invalid signature');
}
// 논스 사용 기록
await redisClient.set(nonceKey, '1', { EX: 60 * 60 });
// 사용자 정보 설정
req.user = { id: decodedToken.sub };
next();
} catch (error) {
return res.status(401).json({ message: `PoP 검증 실패: ${error.message}` });
}
};
JWT 보안 체크리스트
최종 정리를 위한 JWT 보안 체크리스트입니다.
1. 토큰 구성 및 서명
- 적절한 JWT 서명 알고리즘 사용 (HS256, RS256, ES256 등)
- 서명 알고리즘을 명시적으로 지정하여 알고리즘 변조 공격 방지
- 강력한 비밀 키 또는 키 쌍 사용 (최소 256비트 이상)
- 주기적인 키 순환(rotation) 구현
- 비대칭 알고리즘을 사용할 경우 개인 키 안전하게 보관
2. 토큰 페이로드
- 최소한의 필요 정보만 토큰에 포함
- 민감한 정보(비밀번호, 개인정보 등) 포함 금지
- 표준 클레임 활용 (iss, sub, exp, iat, jti 등)
- 적절한 만료 시간(exp) 설정 (액세스 토큰 15분 내외)
- 토큰 식별자(jti) 추가하여 토큰 추적 및 무효화 가능하게 설정
3. 토큰 저장 및 전송
- 액세스 토큰은 메모리에 저장 (웹 스토리지 사용 지양)
- 리프레시 토큰은 HttpOnly, Secure, SameSite 쿠키로 저장
- HTTPS를 통해서만 토큰 전송
- Authorization 헤더를 통한 토큰 전송
- 쿠키 사용 시 적절한 CSRF 방어 구현
4. 토큰 검증
- 모든 보호된 라우트에서 토큰 검증
- 토큰 서명 검증
- 만료 시간(exp) 검증
- 발급자(iss) 검증
- 필요시 블랙리스트 확인
- 중요 작업 시 추가 검증 메커니즘 구현
5. 토큰 갱신 및 무효화
- 리프레시 토큰 순환(rotation) 구현
- 리프레시 토큰 사용 시 액세스 토큰만 발급 (리프레시 토큰은 필요 시에만 교체)
- 리프레시 토큰 사용 추적 및 재사용 감지
- 보안 이슈 발생 시 모든 토큰 무효화 메커니즘 구현
- 로그아웃 시 적절한 토큰 무효화 처리
6. 부가 보안 조치
- 요청량 제한(Rate limiting) 구현
- 의심스러운 활동 모니터링 및 자동 대응
- 보안 헤더 설정 (CSP, X-XSS-Protection 등)
- 중요 작업에 이중 인증(2FA) 또는 이중 토큰 검증 적용
- 요청 서명 또는 PoP(Proof of Possession) 메커니즘 구현
마무리
이번 JWT 인증 시리즈에서는 JWT(JSON Web Token)의 기본 개념부터 고급 보안 기법까지 포괄적으로 다루었습니다. JWT는 현대 웹 애플리케이션에서 인증을 구현하는 강력한 도구이지만, 올바르게 구현하지 않으면 심각한 보안 위협을 초래할 수 있습니다.
미래 발전 방향
JWT 인증 기술은 계속 발전하고 있으며, 다음과 같은 방향으로 확장될 수 있습니다:
- 향상된 사용자 경험
- 컨텍스트 기반 토큰 수명 관리
- 비침투적인 토큰 갱신 메커니즘
- 진보된 세션 관리 기술
- 보안 강화
- 행동 기반 이상 감지
- 머신러닝을 활용한 이상 패턴 감지
- 동적 인증 강도 조절
- 분산 시스템 통합
- 서비스 메시와의 통합
- 오픈 ID Connect와의 결합
- 제로 트러스트 아키텍처 지원
마지막 조언
- 단순함 추구: 복잡한 인증 시스템은 취약점을 증가시킬 수 있습니다. 필요한 기능만 구현하세요.
- 지속적인 업데이트: 보안은 끊임없이 진화합니다. 정기적으로 시스템을 검토하고 새로운 보안 모범 사례를 적용하세요.
- 깊이 있는 방어: JWT만으로는 완벽한 보안을 구축할 수 없습니다. 여러 보안 계층을 구현하세요.
- 사용자 경험 균형: 보안을 강화하면서도 사용자 경험이 저하되지 않도록 균형을 맞추는 것이 중요합니다.
- 철저한 테스트: 인증 시스템은 철저히 테스트해야 합니다. 자동화된 테스트와 정기적인 보안 감사를 수행하세요.
이 시리즈가 여러분의 JWT 인증 시스템 구현에 도움이 되었기를 바랍니다. 안전하고 견고한, 그리고 사용자 친화적인 인증 시스템을 구축하여 애플리케이션의 보안과 사용자 경험을 모두 향상시키시길 바랍니다.
시리즈 요약
- 1편: JWT의 기본 개념과 구조, 작동 원리
- 2편: Node.js와 Express를 활용한 JWT 인증 구현
- 3편: React와 Next.js에서의 클라이언트 측 JWT 관리
- 4편: JWT 보안 강화 기법과 모범 사례
다음 주제로는 "JWT와 소셜 로그인 통합", "마이크로서비스 아키텍처에서의 JWT 활용", "JWT와 GraphQL API 인증" 등을 다루어 볼 예정입니다. 관심 있는 주제가 있으시면 댓글로 알려주세요!
참고 자료
'Study > Authentication' 카테고리의 다른 글
| JWT 인증 시리즈 3편: Frontend에서의 JWT 구현 (React/Next.js) (0) | 2025.04.27 |
|---|---|
| JWT 인증 시리즈 2편: Node.js와 Express를 활용한 JWT 인증 구현 (0) | 2025.04.27 |
| JWT 인증 시리즈 1편: JWT 토큰 원리 (0) | 2025.04.27 |