JWT 취약점: 서명 검증 누락·alg 혼동·약한 시크릿
JWT 취약점은 대부분 구현 실수에서 생깁니다. decode·verify 혼동, alg none, RS256/HS256 혼동, 약한 시크릿, kid·jku 신뢰, 로그아웃 후 재사용 문제를 jsonwebtoken·jjwt·PyJWT 코드 비교로 점검합니다.
JWT 자체가 취약한 것은 아닙니다. 토큰 위조나 인증 우회는 거의 다 검증 코드의 실수에서 생깁니다. 점검 현장에서 반복해서 보는 것은 여섯 가지입니다. 서명을 확인하지 않고 내용만 읽는 decode 사용, alg: none 허용, RS256과 HS256의 혼동, 추측 가능한 HMAC 시크릿, 토큰 헤더의 kid·jku 값을 그대로 믿는 키 선택 로직, 그리고 로그아웃한 뒤에도 쓸 수 있는 토큰입니다.
우리 코드를 점검하는 방법은 단순합니다. 토큰을 검증하는 함수를 모두 찾은 다음, 각 호출이 ① 서버가 정한 알고리즘 목록을 명시하는지, ② 서버가 가진 키로만 검증하는지, ③ 서명 검증을 끄는 옵션이 없는지, ④ 폐기된 토큰을 거르는지 보면 됩니다. 아래에서 항목마다 취약한 코드와 고친 코드를 나란히 보여주고, Node.js(jsonwebtoken), Java(jjwt), Python(PyJWT)의 안전한 검증 코드를 비교합니다.
만료 시간(exp)을 어떻게 잡을지는 이 글에서 다루지 않습니다. 대신 공격자가 토큰을 새로 만들어 내거나, 이미 무효가 되어야 할 토큰을 다시 쓰는 경로에 집중합니다.
1. decode와 verify를 헷갈리는 실수
가장 흔하고 피해도 큰 실수입니다. JWT의 payload는 Base64URL로 인코딩만 되어 있어서 누구나 읽을 수 있습니다. 라이브러리의 decode 계열 함수는 서명을 확인하지 않고 내용만 꺼냅니다. 이 결과를 인증 판단에 쓰면, 공격자가 payload의 sub나 role을 바꾼 토큰을 그대로 통과시키게 됩니다.
// ❌ 취약: 서명 검증 없이 payload만 읽음
const jwt = require('jsonwebtoken');
function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
const payload = jwt.decode(token); // 누구나 만든 토큰이 통과
if (!payload) return res.sendStatus(401);
req.user = { id: payload.sub, role: payload.role };
next();
}
// ✅ 수정: verify로 서명·알고리즘·발급자·대상까지 확인
function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
});
req.user = { id: payload.sub, role: payload.role };
next();
} catch {
return res.sendStatus(401);
}
}
PyJWT에서도 같은 패턴이 나옵니다. jwt.decode()가 기본으로는 검증을 하지만, 디버깅하려고 options={"verify_signature": False}를 넣었다가 그대로 배포되는 일이 있습니다. Java에서는 GitHub CodeQL 규칙이 jjwt의 예전 parse 메서드를 경고합니다. 파서에 서명 키를 설정해 두어도 서명이 비어 있는 JWT를 받아들일 수 있기 때문입니다.
점검 방법: 코드에서 jwt.decode(, verify_signature, get_unverified_header, parseClaimsJwt, .parse(를 검색합니다. 검색된 결과가 인증·인가 판단으로 이어지는지 하나씩 따라가 봅니다. 로그에 남길 목적처럼 판단과 무관한 용도일 때만 decode를 허용합니다.
2. alg none과 알고리즘 목록을 지정하지 않은 검증
JWT 헤더의 alg 값은 공격자가 바꿀 수 있는 입력입니다. OWASP JSON Web Token Cheat Sheet는 일부 라이브러리가 none 알고리즘으로 된 서명 없는 토큰을 유효하다고 처리해서, 공격자가 클레임을 마음대로 바꿀 수 있었던 문제를 설명합니다. RFC 8725(JWT Best Current Practices)도 알고리즘 검증을 첫 번째 권고로 둡니다. 라이브러리는 호출자가 허용한 알고리즘만 받아들여야 한다는 내용입니다.
요즘 주요 라이브러리는 기본값이 안전한 쪽으로 바뀌었습니다.
- jsonwebtoken: v9에서
verify()가 서명 없는 토큰을 기본으로 받아들이던 동작이 없어졌습니다.algorithms에'none'을 직접 적지 않으면 오류가 납니다. - PyJWT: 서명을 검증할 때
algorithms인자를 넘기지 않으면 오류가 납니다(키가 PyJWK 객체이면 그 키의 알고리즘이 기본값이 됩니다). - jjwt 0.12 이후:
parseSignedClaims처럼 "서명된 토큰"을 기대하는 메서드를 쓰도록 API가 정리되었습니다.
문제는 오래된 버전을 쓰고 있거나, 아래처럼 허용 목록을 토큰에서 가져오는 경우입니다.
# ❌ 취약: 토큰 헤더의 alg를 그대로 허용 목록으로 사용
header = jwt.get_unverified_header(token)
payload = jwt.decode(token, key, algorithms=[header["alg"]])
# ✅ 수정: 서버가 정한 고정 목록
payload = jwt.decode(token, key, algorithms=["RS256"])
PyJWT 공식 문서도 algorithms를 토큰의 alg나 공격자가 영향을 줄 수 있는 데이터로 계산하지 말라고 경고합니다. 허용 목록에는 실제로 쓰는 알고리즘 하나만 넣는 것이 좋습니다.
3. RS256/HS256 혼동: 공개키가 HMAC 시크릿이 되는 경우
RS256은 개인키로 서명하고 공개키로 검증합니다. HS256은 같은 시크릿으로 서명과 검증을 모두 합니다. 서버가 "RS256과 HS256을 모두 허용"하고 검증 키 자리에 RSA 공개키를 넘기면 문제가 생깁니다. 라이브러리가 HS256 토큰을 받았을 때 공개키 문자열을 HMAC 시크릿으로 쓸 수 있기 때문입니다. 공개키는 말 그대로 공개되어 있으니, 공격자가 같은 값으로 HMAC 서명을 만들어 낼 수 있습니다. 이를 알고리즘 혼동(key confusion)이라고 부릅니다.
// ❌ 취약: 공개키로 검증하면서 대칭 알고리즘까지 허용
jwt.verify(token, publicKeyPem, { algorithms: ['RS256', 'HS256'] });
// ✅ 수정: 비대칭 키에는 비대칭 알고리즘만
jwt.verify(token, publicKeyPem, { algorithms: ['RS256'] });
jsonwebtoken v9은 비대칭 키의 타입과 알고리즘 조합도 확인합니다. allowInvalidAsymmetricKeyTypes 옵션을 켜 두었다면 왜 켰는지 다시 확인해 보세요. jjwt는 verifyWith에 넘기는 키의 타입(SecretKey인지 PublicKey인지)이 검증 방식을 결정하므로, 키 객체를 정확한 타입으로 만드는 것이 곧 방어가 됩니다.
판단 기준: 한 검증 경로에서는 대칭 알고리즘 계열과 비대칭 알고리즘 계열을 섞지 않습니다. 둘 다 써야 한다면 발급자(iss)별로 검증 경로를 분리합니다.
4. 약한 HMAC 시크릿
HS256 토큰은 서명 값과 원문이 모두 토큰 안에 들어 있습니다. 따라서 토큰을 한 개라도 손에 넣은 사람은 서버와 통신하지 않고도 오프라인에서 시크릿을 추측해 볼 수 있습니다. secret, jwt-secret, 서비스 이름, 예제 코드에 있던 문자열 같은 값은 금방 맞혀집니다. 시크릿이 맞으면 모든 사용자의 토큰을 위조할 수 있게 됩니다.
// ❌ 취약: 사람이 정한 짧은 문자열, 코드에 하드코딩
const JWT_SECRET = process.env.JWT_SECRET || 'my-app-secret';
# ✅ 수정: 256비트 이상 난수로 생성해 시크릿 매니저에 보관
openssl rand -base64 48
// ✅ 수정: 기본값으로 대체하지 말고, 없거나 짧으면 기동을 실패시킴
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || Buffer.byteLength(JWT_SECRET) < 32) {
throw new Error('JWT_SECRET is missing or too short');
}
RFC 8725는 키에 충분한 엔트로피가 있어야 한다고 권고합니다. 실무에서는 HS256에 최소 32바이트 난수를 씁니다. jjwt의 Keys.hmacShaKeyFor()는 알고리즘에 비해 짧은 키를 넣으면 예외를 던지므로 이 실수를 일찍 잡아 줍니다. 시크릿이 깃 이력이나 프런트엔드 번들, 로그에 남지 않았는지도 함께 확인하세요. 점검 방법은 API 키 유출 방지 체크리스트에 정리해 두었습니다. 여러 서비스가 토큰을 검증해야 한다면 시크릿을 나눠 주는 대신 RS256이나 ES256으로 바꾸고 공개키만 배포하는 편이 구조적으로 안전합니다.
5. kid·jku 헤더를 그대로 믿는 키 선택
키를 교체(rotation)하려고 헤더의 kid(키 ID)나 jku(키 세트 URL)로 검증 키를 고르게 만드는 경우가 많습니다. 여기서 헤더 값을 그대로 파일 경로, SQL, URL에 넣으면 공격자가 검증 키를 직접 고르게 됩니다.
kid를 파일 경로나 DB 쿼리에 이어 붙이면 경로 조작이나 SQL 인젝션으로 "내용을 예측할 수 있는 키"를 고르게 할 수 있습니다.jku의 URL에서 키를 받아 오면, 공격자는 자기 서버에 올린 공개키로 서명한 토큰을 통과시킬 수 있습니다. 서버가 그 URL로 요청을 보내므로 SSRF 경로도 됩니다.
# ❌ 취약: 헤더의 jku에서 키를 받아 옴
header = jwt.get_unverified_header(token)
jwks_client = jwt.PyJWKClient(header["jku"])
key = jwks_client.get_signing_key_from_jwt(token)
# ✅ 수정: JWKS URL은 서버 설정값으로 고정, kid는 그 안에서 조회만
JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
jwks_client = jwt.PyJWKClient(JWKS_URL)
signing_key = jwks_client.get_signing_key_from_jwt(token) # kid로 고정 키 세트에서 조회
payload = jwt.decode(
token, signing_key.key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="api.example.com",
)
원칙: kid는 서버가 미리 가진 키 목록(딕셔너리, JWKS 캐시)에서 찾는 데만 씁니다. 목록에 없으면 거부합니다. jku·x5u·jwk 헤더는 검증 키를 정하는 데 쓰지 않습니다. 외부 URL을 서버가 대신 요청하는 위험은 SSRF 취약점 글에서 더 자세히 다뤘습니다.
6. 세 라이브러리의 안전한 검증 코드 비교
같은 정책(RS256 고정, 발급자·대상 확인)을 세 언어로 쓰면 다음과 같습니다.
// Node.js — jsonwebtoken v9+
const payload = jwt.verify(token, publicKeyPem, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
});
// Java — jjwt 0.12+
Jws<Claims> jws = Jwts.parser()
.verifyWith(rsaPublicKey) // PublicKey 타입
.requireIssuer("https://auth.example.com")
.requireAudience("api.example.com")
.build()
.parseSignedClaims(token); // 서명된 토큰만 허용
Claims claims = jws.getPayload();
# Python — PyJWT 2.x
payload = jwt.decode(
token, public_key_pem,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="api.example.com",
)
세 코드의 공통점은 다음과 같습니다. 알고리즘은 서버 쪽에서 고정하고, 키는 서버가 가진 것만 쓰고, 서명된 토큰만 받는 API를 쓰고, iss·aud를 확인합니다. aud 확인을 빼면 같은 인증 서버가 다른 서비스용으로 발급한 토큰이 우리 API에서도 통과할 수 있습니다. RFC 8725가 말하는 대체(substitution) 공격입니다. jjwt에서 parseClaimsJws·parseClaimsJwt 같은 예전 메서드를 쓰고 있다면 버전을 올리면서 parseSignedClaims로 바꾸는 것을 권합니다. 라이브러리 버전 점검 방법은 오픈소스 라이브러리 취약점 점검을 참고하세요.
7. 로그아웃·권한 변경 뒤의 토큰 재사용
JWT는 서버에 상태를 두지 않는 것이 장점입니다. 그래서 로그아웃 버튼을 눌러도 클라이언트에서 토큰을 지울 뿐, 서버는 그 토큰을 여전히 유효하다고 판단합니다. 탈취된 토큰, 비밀번호를 바꾸기 전에 발급된 토큰, 권한을 회수당한 관리자의 토큰이 계속 통한다는 뜻입니다.
// ❌ 취약: 로그아웃이 쿠키 삭제뿐
app.post('/logout', (req, res) => {
res.clearCookie('access_token');
res.sendStatus(204);
});
// ✅ 수정 1: jti 거부 목록 (남은 유효기간만큼만 보관)
app.post('/logout', auth, async (req, res) => {
const { jti, exp } = req.tokenPayload;
const ttl = Math.max(exp - Math.floor(Date.now() / 1000), 1);
await redis.set(`jwt:revoked:${jti}`, '1', 'EX', ttl);
res.clearCookie('access_token');
res.sendStatus(204);
});
// 검증 미들웨어에서 서명 검증 직후
if (await redis.exists(`jwt:revoked:${payload.jti}`)) return res.sendStatus(401);
// ✅ 수정 2: 사용자별 토큰 버전 (비밀번호 변경·전체 로그아웃·권한 회수용)
// 발급 시 payload에 tv: user.tokenVersion 포함
const user = await User.findById(payload.sub);
if (!user || user.tokenVersion !== payload.tv) return res.sendStatus(401);
// 비밀번호 변경·권한 회수 시 user.tokenVersion += 1
판단 기준: 일반 서비스는 짧은 access token과 서버에 저장하는 refresh token을 함께 쓰고, refresh token을 쓸 때마다 새로 바꾸는(rotation) 구조로 대부분 해결됩니다. 관리자 기능이나 결제처럼 권한 회수가 즉시 반영되어야 하는 경로에는 위의 거부 목록이나 토큰 버전 확인을 추가합니다. 결국 상태 확인이 조금 들어가지만, 모든 요청이 아니라 위험한 경로에만 적용하면 성능 부담은 크지 않습니다. 로그인 흐름 전체를 점검하려면 로그인 보안 체크리스트도 함께 보세요.
8. 배포 전 JWT 검증 체크리스트
- 인증·인가 판단에
decode나verify_signature: False, 예전parse메서드를 쓰는 곳이 없다 - 모든 검증 호출에 알고리즘 허용 목록을 상수로 지정했다(토큰의
alg에서 가져오지 않는다) - 한 검증 경로에서 HS 계열과 RS·ES 계열을 함께 허용하지 않는다
- HMAC 시크릿은 32바이트 이상 난수이고, 코드에 기본값이 없으며, 시크릿 매니저에 있다
-
kid는 서버의 고정 키 목록에서 조회만 하고,jku·x5u·jwk헤더로 키를 정하지 않는다 -
iss와aud를 검증한다 - 로그아웃·비밀번호 변경·권한 회수 후 기존 토큰이 거부되는지 테스트했다
- jsonwebtoken v9 이상, jjwt 0.12 이상, PyJWT 2.x 최신 패치 버전을 쓴다
이 항목은 단위 테스트로 고정해 두는 것이 가장 확실합니다. 서명 부분을 지운 토큰, alg를 바꾼 토큰, 다른 aud로 발급한 토큰, 로그아웃한 토큰을 테스트 픽스처로 만들어 두고 모두 401이 나오는지 CI에서 확인하면 회귀를 막을 수 있습니다.
자주 묻는 질문
최신 라이브러리를 쓰면 alg none 문제는 신경 쓰지 않아도 되나요?
기본값은 안전해졌지만, 알고리즘 목록을 토큰에서 가져오거나 직접 'none'을 넣는 식의 코드 실수까지 막아 주지는 않습니다. 버전 업데이트와 함께 허용 목록을 상수로 지정했는지 확인하세요.
HS256 대신 RS256을 써야 하나요?
토큰을 발급하는 서비스와 검증하는 서비스가 같고 시크릿을 잘 관리한다면 HS256도 괜찮습니다. 검증하는 서비스가 여러 개이거나 외부에 검증 키를 줘야 한다면, 공개키만 배포하면 되는 RS256이나 ES256이 낫습니다.
토큰 거부 목록을 두면 JWT를 쓰는 의미가 없지 않나요?
모든 요청에 적용하면 세션 방식과 비슷해지는 것이 맞습니다. 그래서 짧은 access token과 refresh token rotation을 기본으로 하고, 즉시 회수가 필요한 경로에만 거부 목록이나 토큰 버전 확인을 쓰는 절충이 일반적입니다.
정리
- JWT 취약점의 대부분은 서명을 검증하지 않거나 헤더(
alg,kid,jku)를 믿는 구현 실수에서 생깁니다. - 알고리즘·키·발급자·대상은 서버가 정하고, 토큰은 그 조건을 만족하는지 확인받기만 해야 합니다.
- HMAC 시크릿은 충분히 긴 난수로 만들고, 로그아웃과 권한 회수 뒤의 토큰 재사용은 거부 목록이나 토큰 버전으로 막습니다.
- 위의 체크리스트를 테스트 코드로 만들어 두면 같은 실수가 다시 들어오는 것을 막을 수 있으며, 직접 점검하기 어렵다면 무료 분석 신청으로 검토를 받아볼 수도 있습니다.
참고 자료
함께 읽으면 좋은 글
모의해킹 준비: 범위·테스트 계정·환경 정하는 법
모의해킹 준비를 처음 하는 팀을 위한 의뢰 전 체크리스트입니다. 점검 범위, 권한별 테스트 계정, 운영·스테이징 선택, 결제·SMS 연동, WAF 예외, 백업까지 의뢰서 템플릿으로 정리했습니다.
개인정보 마스킹 체크리스트: 화면·API·로그·엑셀 12곳
개인정보 마스킹을 화면에서만 하면 API 응답·로그·Sentry·엑셀에는 원문이 남습니다. 이름·전화번호·이메일·주소 규칙과 데이터 경로 12곳 점검표를 코드 예시와 함께 정리했습니다.
문자 인증 보안 체크리스트: SMS 펌핑·인증 우회 막는 10가지
문자 인증 보안 점검 항목 10가지. SMS 펌핑 비용 폭탄을 막는 발송 제한, 인증번호 시도 횟수·만료, 클라이언트 응답 조작 우회, 번호 불일치까지 취약한 코드와 고친 코드로 정리했다.