JWT 인증 구현 시 놓치기 쉬운 보안 포인트 7가지
나는 개발하면서 JWT 관련 보안 문제를 꽤 많이 봤다. 특히 프로덕션에서 localStorage에 토큰을 저장하는 코드를 본 적이 있는데, XSS 한 방이면 끝나는 상황이었다. JWT는 간단해 보이지만 실제로 구현할 때는 놓치기 쉬운 보안 포인트가 많다. 이 글에서는 내가 경험한 실수들과 해결책을 정리했다.
1. 토큰 저장 위치: httpOnly Cookie vs localStorage
가장 흔한 실수는 JWT를 localStorage에 저장하는 것이다. 이 경우 XSS 공격으로 토큰이 탈취될 수 있다. 반면 httpOnly 쿠키는 JavaScript에서 접근할 수 없어 더 안전하다.
// ❌ 위험한 방식
localStorage.setItem('token', jwtToken);
// ✅ 안전한 방식
response.cookie('token', jwtToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
});
httpOnly 쿠키를 사용하면 자동으로 모든 요청에 포함되므로, 클라이언트에서 별도로 헤더를 설정할 필요가 없다. 하지만 CORS 환경에서는 credentials 옵션을 설정해야 한다.
// fetch 요청에서
fetch('https://api.example.com/data', {
credentials: 'include' // 쿠키를 포함시킴
});
// axios에서
axios.defaults.withCredentials = true;
2. 리프레시 토큰 회전 (Refresh Token Rotation)
액세스 토큰이 짧은 수명을 가지고 있다면, 리프레시 토큰은 더 오래 유지된다. 문제는 리프레시 토큰이 탈취되었을 때 감지하기 어렵다는 것이다. 이를 해결하기 위해 리프레시 토큰 회전을 구현해야 한다.
// Express 미들웨어 예제
app.post('/refresh', (req, res) => {
const oldRefreshToken = req.cookies.refreshToken;
if (!oldRefreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const decoded = jwt.verify(oldRefreshToken, REFRESH_SECRET);
// 새로운 토큰 발급
const newAccessToken = jwt.sign(
{ userId: decoded.userId, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const newRefreshToken = jwt.sign(
{ userId: decoded.userId, type: 'refresh', tokenVersion: decoded.tokenVersion + 1 },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 쿠키에 새 토큰 저장
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
res.json({ accessToken: newAccessToken });
} catch (error) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
중요한 점은 토큰 버전을 트래킹하는 것이다. 사용자가 비밀번호를 변경하거나 로그아웃할 때 토큰 버전을 증가시키면, 이전 토큰은 자동으로 무효화된다.
3. 알고리즘 혼동 공격 (Algorithm Confusion)
JWT 헤더의 "alg" 필드를 검증하지 않으면 공격자가 알고리즘을 변경할 수 있다. 특히 HS256(대칭키)과 RS256(비대칭키)을 혼동하게 하는 공격이 있다.
// ❌ 위험한 방식
function verifyToken(token) {
return jwt.verify(token, secret);
}
// ✅ 안전한 방식
function verifyToken(token) {
return jwt.verify(token, secret, {
algorithms: ['HS256'] // 명시적으로 허용할 알고리즘 지정
});
}
// RS256 사용 시
function verifyToken(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256']
});
}
항상 서버가 예상하는 알고리즘을 명시적으로 지정해야 한다. 만약 HS256을 사용 중이라면 절대 RS256으로 전환하지 말고, 대신 새로운 알고리즘이 필요하면 새로운 토큰 타입을 만들어야 한다.
4. CSRF 방어와 JWT
JWT가 쿠키에 저장되면 CSRF 공격 위험이 있다. httpOnly 쿠키는 이미 자동으로 전송되므로, CSRF 토큰이 필요하다.
// CSRF 토큰 생성 및 검증
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.use(csrf({ cookie: false })); // 메모리 저장 방식
app.get('/form', (req, res) => {
// 클라이언트에 CSRF 토큰 전달
res.json({ csrfToken: req.csrfToken() });
});
app.post('/data', (req, res) => {
// 자동으로 CSRF 토큰 검증됨
res.json({ success: true });
});
또는 커스텀 헤더를 사용하는 방법도 있다. 브라우저의 SOP 정책상 JavaScript에서 커스텀 헤더를 추가하려면 같은 출처에서만 가능하므로, 이것도 일종의 CSRF 방어가 된다.
5. 토큰 무효화 전략
JWT의 가장 큰 문제는 서버에서 토큰을 무효화할 수 없다는 것이다. 토큰이 발급된 후에는 만료될 때까지 유효하다. 이를 해결하기 위한 전략들이 있다.
블랙리스트 방식: 로그아웃한 토큰을 Redis에 저장하고, 검증할 때 확인한다.
// 로그아웃 시
app.post('/logout', authenticate, (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
// Redis에 토큰을 블랙리스트에 추가
redis.setex(`blacklist:${token}`, ttl, '1');
res.json({ success: true });
});
// 토큰 검증 미들웨어
async function authenticate(req, res, next) {
const token = req.headers.authorization.split(' ')[1];
// 블랙리스트 확인
const isBlacklisted = await redis.get(`blacklist:${token}`);
if (isBlacklisted) {
return res.status(401).json({ error: 'Token revoked' });
}
try {
req.user = jwt.verify(token, ACCESS_SECRET);
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
토큰 버전 방식: 사용자별 토큰 버전을 데이터베이스에 저장하고, 토큰의 버전과 비교한다.
// 토큰 검증 시
async function authenticate(req, res, next) {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, ACCESS_SECRET);
// 데이터베이스에서 사용자의 현재 토큰 버전 확인
const user = await User.findById(decoded.userId);
if (!user || user.tokenVersion !== decoded.tokenVersion) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = decoded;
next();
}
// 로그아웃 시 (또는 비밀번호 변경 시)
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
6. 비밀키 관리
JWT의 서명에 사용하는 비밀키는 환경변수로 관리해야 한다. 절대 코드에 하드코딩하면 안 된다.
// .env 파일
ACCESS_SECRET=your_very_long_random_secret_key_here
REFRESH_SECRET=another_very_long_random_secret_key_here
// 코드에서
require('dotenv').config();
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
// 프로덕션 환경에서는 AWS Secrets Manager, HashiCorp Vault 등 사용
const secretsManager = new AWS.SecretsManager();
const secret = await secretsManager.getSecretValue({ SecretId: 'jwt-secret' }).promise();
또한 비밀키는 정기적으로 로테이션해야 한다. 예를 들어 RS256을 사용한다면, 새로운 키 쌍을 생성하고 기존 키로 발급한 토큰은 일정 기간 후 무효화하는 방식이다.
7. 페이로드 크기
JWT는 서명되었을 뿐 암호화되지 않은 토큰이다. Base64로 디코딩하면 누구나 내용을 볼 수 있다. 따라서 민감한 정보를 넣으면 안 된다.
// ❌ 위험한 방식
const token = jwt.sign({
userId: user.id,
email: user.email,
passwordHash: user.passwordHash, // 절대 금지!
creditCard: user.creditCard // 절대 금지!
}, SECRET);
// ✅ 안전한 방식
const token = jwt.sign({
userId: user.id,
role: user.role,
iat: Math.floor(Date.now() / 1000)
}, SECRET, { expiresIn: '15m' });
또한 토큰이 너무 커지면 모든 요청에 포함되기 때문에 네트워크 성능에 영향을 미친다. 필요한 최소한의 정보만 담아야 한다.
정리
JWT 보안의 핵심은 다음과 같다:
- 토큰은 httpOnly 쿠키에 저장
- 리프레시 토큰 회전 구현
- 허용할 알고리즘 명시적으로 지정
- CSRF 토큰 추가
- 토큰 무효화 전략 수립
- 비밀키는 안전하게 관리
- 민감한 정보는 토큰에 담지 않기
이 7가지를 모두 적용하면 상당히 안전한 JWT 시스템을 만들 수 있다.