JWT 구조 읽는 법과 디코딩이 검증이 아닌 이유
JWT는 점(.)으로 구분된 세 덩어리, 즉 헤더·페이로드·서명으로 이루어집니다. 앞의 두 덩어리는 base64url로 인코딩되어 있을 뿐 암호화되어 있지 않으므로 누구나 읽을 수 있습니다. 이 도구는 그 두 덩어리를 디코딩해 보여줍니다. 여기서 반드시 구분해야 할 것이 있습니다. 디코딩은 검증이 아닙니다.
세 부분의 구조
토큰은 header.payload.signature 형태입니다. 헤더는 서명 알고리즘(alg)과 토큰 종류(typ), 키를 고를 때 쓰는 키 식별자(kid)를 담습니다. 페이로드는 클레임이라 부르는 키-값 쌍의 모음입니다. 서명은 앞의 두 부분을 이어 붙인 문자열에 비밀키나 개인키를 적용해 만든 값입니다.
header = {"alg":"HS256","typ":"JWT"}
payload = {"sub":"1234","name":"홍길동","exp":1735689600}
signingInput = base64url(header) + "." + base64url(payload)
signature = HMACSHA256(signingInput, secret)
token = signingInput + "." + base64url(signature)표준 클레임의 의미
RFC 7519가 정의한 등록 클레임은 이름이 짧습니다. 바이트를 아끼기 위한 설계입니다.
| 클레임 | 이름 | 설명 |
|---|---|---|
| iss | issuer | 토큰을 발급한 주체 |
| sub | subject | 토큰이 가리키는 대상, 보통 사용자 ID |
| aud | audience | 이 토큰을 받아들여야 하는 대상. 검증 시 반드시 확인 |
| exp | expiration time | 만료 시각(Unix 초). 이 시각 이후 거부 |
| nbf | not before | 이 시각 이전에는 유효하지 않음 |
| iat | issued at | 발급 시각 |
| jti | JWT ID | 토큰 고유 식별자. 재사용 차단이나 블랙리스트에 사용 |
사용 방법
- JWT 문자열 전체를 붙여넣습니다. Bearer 접두사는 제외하고 eyJ... 부터 넣으세요.
- 헤더와 페이로드가 JSON으로 표시됩니다. alg 값과 클레임을 확인합니다.
- exp가 있으면 현재 시각과 비교해 만료 여부를 확인합니다.
- 서명 부분은 표시되지만 이 도구는 유효성을 검증하지 않습니다. 검증에는 비밀키가 필요합니다.
디코딩과 검증의 차이
디코딩은 base64url을 되돌려 글자를 읽는 일이고, 키가 전혀 필요하지 않습니다. 검증은 서명이 그 페이로드와 그 키로 만들어진 것이 맞는지 암호학적으로 확인하는 일입니다. 서버가 해야 할 것은 검증이며, 디코딩만 하고 페이로드를 신뢰하면 공격자가 페이로드를 임의로 바꿔 넣을 수 있습니다.
실제로 자주 보이는 취약한 코드가 jwt.decode()입니다. 많은 라이브러리에서 decode는 서명을 확인하지 않고, verify가 확인합니다. 인증 미들웨어에서 decode를 쓰고 있다면 그것은 인증이 아니라 그냥 파싱입니다.
// 위험: 서명을 확인하지 않음
const payload = jwt.decode(token);
if (payload.role === "admin") { /* 누구나 통과 */ }
// 올바름: 서명·만료·발급자·대상까지 검증
const payload = jwt.verify(token, secret, {
algorithms: ["HS256"], // 알고리즘 고정
issuer: "https://auth.example.com",
audience: "my-api",
});알고리즘 혼동 공격
JWT의 유명한 취약점 두 가지가 모두 헤더의 alg 값을 신뢰하는 데서 생깁니다. 첫째는 alg를 none으로 바꿔 서명을 비우는 공격입니다. 검증 함수가 none을 허용하도록 구현되어 있으면 서명 없는 토큰이 통과합니다.
둘째는 RS256을 HS256으로 바꾸는 공격입니다. RS256은 개인키로 서명하고 공개키로 검증하는데, 공격자가 alg를 HS256으로 바꾸면 검증 코드가 공개키를 HMAC의 비밀키로 사용하게 됩니다. 공개키는 이름 그대로 공개되어 있으므로 공격자가 유효한 서명을 만들 수 있습니다.
두 공격의 공통 방어책은 하나입니다. 검증할 때 허용 알고리즘을 코드에 명시하고, 토큰 헤더가 말하는 alg를 따르지 않는 것입니다.
페이로드에 넣지 말아야 할 것
- 비밀번호, 카드번호, 주민등록번호 등 노출되면 안 되는 값 — 페이로드는 누구나 읽습니다
- 자주 바뀌는 권한 정보 — 토큰은 만료 전까지 취소하기 어렵습니다
- 큰 데이터 — JWT는 매 요청 헤더에 실려 다니므로 용량이 곧 대역폭 비용입니다
- 내부 시스템 구조를 드러내는 식별자 — 정보 노출로 이어질 수 있습니다
자주 묻는 질문
- 이 도구에 실제 운영 토큰을 붙여넣어도 되나요?
- 디코딩은 브라우저에서만 이루어지고 네트워크로 전송되지 않습니다. 다만 유효한 액세스 토큰은 그 자체가 인증 수단이므로, 만료되지 않은 운영 토큰을 외부 사이트에 붙여넣는 것은 일반적으로 권장하지 않습니다. 가능하면 개발 환경 토큰이나 만료된 토큰으로 확인하세요.
- JWT는 암호화되어 있나요?
- 일반적인 JWS 형식의 JWT는 암호화가 아니라 서명만 되어 있습니다. 즉 내용 변경은 탐지할 수 있지만 내용 열람은 막지 못합니다. 내용을 감춰야 한다면 JWE(JSON Web Encryption)를 써야 하지만, 실무에서는 대개 민감한 정보를 페이로드에 넣지 않는 방식으로 해결합니다.
- exp가 지났는데도 서버가 받아들입니다.
- 검증 옵션에서 만료 확인이 꺼져 있거나(ignoreExpiration), 서버 시계가 크게 어긋나 있거나, 검증 없이 decode만 하고 있을 가능성이 큽니다. exp는 UTC 기준 Unix 초이므로 밀리초와 혼동해 1000배 큰 값을 넣는 실수도 흔합니다.
- 토큰을 즉시 무효화할 수 있나요?
- JWT의 설계상 서버가 상태를 갖지 않으므로 발급된 토큰을 직접 취소할 수 없습니다. 현실적인 대응은 액세스 토큰 만료를 짧게(수 분~수십 분) 두고 리프레시 토큰으로 갱신하며, 즉시 차단이 필요한 경우 jti 기반 블랙리스트나 사용자별 토큰 버전 값을 서버에 두는 것입니다. 후자를 도입하면 무상태의 이점은 일부 포기하게 됩니다.
- 토큰을 어디에 저장해야 하나요?
- localStorage는 XSS로 스크립트가 실행되면 그대로 읽히므로 위험합니다. HttpOnly·Secure·SameSite 속성을 붙인 쿠키가 XSS에는 더 안전하지만 CSRF 대비가 필요합니다. 일반적인 권장은 리프레시 토큰을 HttpOnly 쿠키에 두고 액세스 토큰은 메모리에만 유지하는 조합입니다.
- kid는 무엇에 쓰나요?
- 키 식별자입니다. 발급자가 여러 키를 운영하거나 키를 교체할 때, 검증하는 쪽이 어느 공개키를 써야 하는지 고르는 데 씁니다. OIDC 환경에서는 JWKS 엔드포인트에서 키 목록을 받아 kid로 매칭합니다. 다만 kid 값을 파일 경로나 SQL에 그대로 넣는 구현은 경로 조작·인젝션 취약점이 되므로 반드시 검증해야 합니다.
💡 참고: 토큰이 세 덩어리가 아니라 다섯 덩어리로 나뉘어 있다면 JWS가 아니라 JWE(암호화된 JWT)입니다. 이 경우 키 없이는 페이로드를 읽을 수 없습니다.