JWT를 쓰는 코드를 리뷰할 때 보는 항목들을 정리했어요. 결론부터 말하면 JWT 사고의 대부분은 규격 자체가 아니라 쓰는 방식에서 나요. 디코딩만 하고 검증을 안 하거나, 헤더의 alg 를 그대로 믿거나, 약한 비밀 키를 쓰거나, 토큰을 스크립트가 읽을 수 있는 곳에 두는 것. 검증 옵션에 algorithms·audience·issuer 를 명시하는 것만으로 치명적인 네 가지가 막혀요.
주요 라이브러리(jsonwebtoken, jose 등)는 이미 이런 위험을 기본으로 막고 있어요. 문제는 옵션을 대충 넘기거나 검증을 직접 구현할 때예요. 각 항목을 Node.js 22.18과 jsonwebtoken 9.x의 실제 동작으로 확인하면서, 리뷰에서 무엇을 봐야 하는지 정리했어요. 이 글은 내 서비스를 지키기 위한 방어 관점의 체크리스트예요.
핵심 요약
decode는 디버깅용이고, 서버는 반드시 verify로 서명과 exp·aud·iss를 확인해야 해요.
검증 라이브러리에 허용 알고리즘을 명시하면 alg none·알고리즘 혼동 관련 위험이 막혀요.
secret123 같은 짧은 비밀 키는 안전하지 않아요. HS256 키는 256비트 이상의 무작위 값이어야 해요.
localStorage는 XSS에 취약하고 HttpOnly 쿠키는 CSRF 대책이 필요해요. 결국 XSS를 막는 게 근본이에요.
1. 디코딩만 하고 검증을 안 한다
제일 치명적이고 의외로 흔해요. 토큰 페이로드를 Base64로 풀어서 sub 나 role 을 꺼내 쓰는 코드예요.
// 위험: 페이로드는 서명이 확인되지 않은 값이다
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url'));
if (payload.role === 'admin') { /* 관리자 기능 */ }
// 안전: 서명·만료·발급자·대상을 검증한 결과만 쓴다
const payload = jwt.verify(token, key, {
algorithms: ['RS256'], issuer: 'https://auth.example.com', audience: 'api.example.com',
});
페이로드는 JWT 디코더나 Base64 디코더로 누구나 읽을 수 있어요. jwt.decode() 처럼 서명을 확인하지 않는 함수는 로그나 디버깅에만 쓰세요. 프런트엔드에서 화면 표시용으로 디코딩하는 건 괜찮지만, 권한 판단은 반드시 서버가 verify로 확인한 뒤에 해요.
2. 허용 알고리즘을 지정하지 않는다
JWT 헤더의 alg 는 토큰에 적혀 오는 값이에요. 검증하는 쪽이 이 값을 그대로 따르지 않고, 서버가 기대하는 알고리즘을 명시적으로 고정하는 게 핵심 습관이에요.
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
이 옵션을 빼면, 검증기 구현에 따라 서명이 없는 토큰이나 다른 방식으로 서명된 토큰을 통과시킬 여지가 생겨요. 실제로 과거 여러 라이브러리에서 이와 관련된 취약점이 공개됐고(CVE로 등록된 사례들이 있어요), 지금 주요 라이브러리는 기본값으로 막고 있어요. 그래도 algorithms 를 명시하면 라이브러리 기본값에 기대지 않고 의도를 코드로 못박을 수 있어요. jsonwebtoken 9.x에서 algorithms 를 지정한 뒤 다른 알고리즘 토큰을 넣으면 JsonWebTokenError: invalid algorithm, 서명이 비어 있으면 JsonWebTokenError: jwt signature is required 가 나요.
3. 대칭·비대칭 알고리즘을 섞어 받는다
RS256처럼 공개 키로 검증하는 방식과 HS256처럼 비밀 키로 검증하는 방식을 한 엔드포인트에서 모두 허용하면 위험해요. 공개 키는 말 그대로 공개돼 있기 때문에, 검증기가 공개 키를 HMAC 키로도 쓸 수 있는 구조면 문제가 생겨요. 처방은 2번과 같아요. 검증할 때 허용 알고리즘을 하나(또는 같은 계열)로 고정하면 돼요. RS256을 쓴다면 algorithms: ['RS256'], 검증 키는 공개 키만 넘기고 대칭 키 경로는 아예 열어 두지 않는 거예요.
4. 클레임 검사를 빼먹는다
서명만 맞으면 끝이 아니에요. 다음 클레임을 확인해야 해요.
| 클레임 | 확인 내용 | 안 하면 생기는 일 |
|---|---|---|
exp | 만료됐는지 | 오래된 토큰이 계속 통함 |
nbf | 아직 유효 시작 전인지 | 선발급 토큰이 미리 쓰임 |
aud | 이 서비스용 토큰인지 | 다른 서비스용 토큰이 통함 |
iss | 신뢰하는 발급자인지 | 엉뚱한 발급자 토큰 수용 |
같은 인증 서버를 쓰는 여러 서비스에서 aud 를 확인하지 않으면, A 서비스용으로 발급된 토큰이 B 서비스 API에서도 통하는 일이 생겨요. jsonwebtoken 은 audience, issuer 옵션을 주면 자동으로 확인하고, 불일치 시 jwt audience invalid. expected: ... 같은 메시지를 내요. 토큰 만료 설계는 액세스·리프레시 토큰 만료 설계에 정리했어요.
5. HS256 비밀 키가 약하다
HMAC 비밀 키가 secret, jwt-key-1234, secret123 같은 짧고 뻔한 문자열이면 안전하지 않아요. 공격자가 토큰 하나만 손에 넣으면, 서버와 통신하지 않고도(오프라인으로) 후보 문자열들로 서명을 맞춰 보며 키를 찾을 수 있어요. HMAC-SHA256은 설계상 매우 빨라서 — 직접 재 보니 1스레드로 초당 약 40만 회 — 이런 대입이 공격자에게 유리해요.
사전 단어 1만 개는 0.03초, 소문자 6자리도 13분이면 끝나요. GPU를 쓰면 이보다 훨씬 빨라져요. 그래서 HS256 키는 최소 256비트(32바이트) 이상의 무작위 값이어야 해요. crypto.randomBytes(32).toString('base64') 로 만들고, 코드 저장소가 아니라 환경 변수나 비밀 관리 서비스에 두세요. 여러 서비스가 검증해야 한다면 애초에 공개 키로 검증하는 RS256·ES256이 더 안전해요(HS256 vs RS256 비교는 JWT 구조와 서명 원리 참고).
6. 키 교체 계획이 없다
키가 유출됐을 때 어떻게 바꿀지 미리 정해 두지 않으면, 막상 일이 터졌을 때 모든 사용자를 강제 로그아웃시키는 것 말고 방법이 없어요. 헤더의 kid(key id)로 여러 키를 구분하고, 새 키와 옛 키를 잠깐 함께 받아 주는 기간을 두면 교체가 부드러워요. 공개 키 방식이라면 JWKS 엔드포인트로 공개 키를 배포하고, 검증하는 쪽이 kid 로 맞는 키를 골라 쓰게 해요. 정기적인 키 교체(rotation)를 일정으로 잡아 두면 유출을 늦게 알아챘을 때의 피해도 줄어요.
7. 페이로드에 민감 정보를 넣는다
페이로드는 암호화되지 않아요. 도구에 붙여 넣기만 해도 다 보여요. 주민등록번호, 전화번호, 이메일, 집 주소, 내부 권한 구조의 상세 같은 건 넣지 마세요. 사용자 ID와 꼭 필요한 최소한의 권한 정도면 충분해요. 내용을 꼭 숨겨야 한다면 JWE(암호화된 JWT) 같은 별도 규격이 있지만, 보통은 "안 넣는 것"이 가장 간단한 해결이에요.
8. 토큰이 너무 크다
권한 목록을 전부 넣다 보면 토큰이 수 KB가 돼요. 실제로 resource:read 형태 권한을 늘려 가며 토큰 크기를 재 봤어요.
권한 100개면 2.5KB — 모든 요청 헤더에 실리고, 쿠키 4KB 한도에도 가까워진다
표로 보기
| 구분 | 토큰 길이 |
|---|---|
| 0개 | 165 |
| 10개 | 391 |
| 30개 | 871 |
| 60개 | 1,591 |
| 100개 | 2,551 |
| 200개 | 5,084 |
토큰은 모든 요청 헤더에 실리니, 커지면 트래픽이 늘고 쿠키 크기 제한(보통 4KB)에 걸리기도 해요. 권한이 많다면 토큰에는 역할(role)만 넣고, 세부 권한은 서버가 역할로 조회하는 게 나아요.
9. 저장 위치 — localStorage와 쿠키 비교
토큰을 어디에 두느냐는 "어떤 공격에 더 취약한가"의 문제예요.
| 저장 위치 | 스크립트 접근 | 주 위험 | 필요한 대책 |
|---|---|---|---|
| localStorage / sessionStorage | 가능 | XSS로 토큰 유출 | XSS 방지 (CSP, 입력 이스케이프) |
| JS로 읽는 쿠키 | 가능 | XSS로 토큰 유출 | 위와 같음 |
| HttpOnly·Secure·SameSite 쿠키 | 불가능 | CSRF | SameSite, CSRF 토큰 |
| 메모리(변수) | 같은 탭만 | 새로고침 시 소실 | 리프레시로 복구 |
localStorage는 자바스크립트로 읽을 수 있어서, 사이트에 XSS 취약점이 하나라도 생기면 토큰이 빠져나갈 수 있어요. 대안으로 많이 쓰는 건 HttpOnly, Secure, SameSite 속성을 붙인 쿠키예요. 스크립트에서 읽을 수 없으니 XSS로 토큰 자체를 가져가기는 어려워져요. 대신 쿠키는 요청에 자동으로 실리니 CSRF 대책(SameSite=Lax/Strict, CSRF 토큰)을 함께 챙겨야 해요. 액세스 토큰은 메모리에, 리프레시 토큰은 HttpOnly 쿠키에 두는 조합도 흔해요. 어느 쪽도 완벽하지 않고, 결국 XSS를 막는 게 근본이에요.
10. 토큰이 URL이나 로그에 남는다
쿼리 문자열에 토큰을 넣으면 브라우저 기록, 서버 접근 로그, Referer 헤더로 새어 나가요. 에러 로그에 요청 헤더를 통째로 찍는 것도 같은 문제예요. 토큰은 Authorization 헤더나 쿠키로 보내고, 로그 수집 단계에서 Authorization 헤더와 쿠키 값은 마스킹하세요. 외부 모니터링 서비스로 요청을 보내는 경우 특히 조심해야 해요.
리뷰 순서 요약
열 개 중 1~4번은 "검증 옵션 한 줄"로 대부분 해결돼요. 그래서 저는 리뷰할 때 제일 먼저 verify 호출부의 옵션 객체부터 봐요.
jwt.verify(token, key, {
algorithms: ['RS256'], // 2·3번
issuer: 'https://auth.example.com', // 4번
audience: 'api.example.com', // 4번
clockTolerance: 30, // 시계 오차
});
여기에 algorithms, audience, issuer 가 들어 있으면 일단 한숨 돌려요. 그다음 키가 무작위 값인지(5번), 페이로드에 민감 정보가 없는지(7번), 토큰을 어디에 저장하는지(9번)를 봐요.
자주 묻는 질문
JWT 검증할 때 algorithms 옵션은 왜 꼭 넣어야 하나요?
헤더의 alg 는 토큰에 적혀 오는 값이라, 검증기가 이를 그대로 따르면 서버가 의도하지 않은 방식으로 검증될 수 있어요. 허용 알고리즘을 서버에서 고정하면 라이브러리 기본값에 기대지 않고 의도를 코드로 명시할 수 있고, alg none이나 대칭·비대칭 혼동과 관련된 위험을 확실히 닫을 수 있어요.
HS256 비밀 키는 얼마나 길어야 하나요?
최소 256비트, 즉 32바이트 이상의 무작위 값을 권장해요. secret 이나 password 같은 사전 단어, 짧은 영숫자 조합은 오프라인 대입에 취약해요. crypto.randomBytes(32) 로 만들어 환경 변수나 비밀 관리 서비스에 보관하세요.
JWT를 localStorage에 저장하면 안 되나요?
localStorage는 자바스크립트로 읽을 수 있어서 XSS가 있으면 토큰이 유출돼요. HttpOnly 쿠키는 스크립트가 못 읽어 토큰 유출은 막지만 CSRF 대책이 필요해요. 어느 방식이든 XSS 자체를 막는 것(CSP, 입력 이스케이프)이 가장 중요해요.
페이로드에 권한을 다 넣어도 되나요?
권한이 적으면 괜찮지만 많아지면 토큰이 커져요. 100개면 2.5KB 정도라 모든 요청에 부담이 되고 쿠키 한도에도 가까워져요. 역할만 토큰에 넣고 세부 권한은 서버가 조회하는 방식이 더 가볍고 변경에도 유연해요.
로그아웃한 사용자의 JWT를 즉시 막을 수 있나요?
액세스 토큰은 서명만으로 검증되니 기본적으로는 만료될 때까지 유효해요. 즉시 차단이 필요하면 jti 를 폐기 목록에 넣어 검증 때 확인하거나, 사용자별 토큰 버전을 올리는 방식을 써야 해요. 이런 요구가 강하면 서버 세션이 더 맞을 수도 있어요.
정리
JWT 보안은 "규격을 믿되 내 코드를 의심하라"로 요약돼요. 검증은 반드시 하고, 허용 알고리즘을 고정하고, 클레임을 확인하고, 키는 무작위 값으로, 페이로드엔 최소 정보만, 저장은 XSS·CSRF를 함께 고려해서. 특히 1~4번은 verify 옵션 한 줄로 해결되니 리뷰에서 가장 먼저 보세요.
토큰 내용 확인은 JWT 디코더로, 구조와 서명 원리는 JWT 구조와 서명 원리에서 이어서 볼 수 있어요.