세 부분, 점 두 개
서명된 JWT(엄밀히는 RFC 7515의 JWS 압축 직렬화에 RFC 7519의 클레임을 담은 것)는 헤더.페이로드.서명 꼴입니다. 헤더와 페이로드는 각각 Base64URL로 인코딩한 JSON 객체입니다. 서명은 인코딩된 앞의 두 부분을 점으로 이은 문자열에 대해 계산되므로, 헤더나 페이로드의 한 글자만 바뀌어도 서명이 무효가 됩니다.
아래는 실제로 만든 데모 토큰입니다. HS256과 버려도 되는 비밀키 demo-secret-not-for-production으로 생성했으므로 어느 디코더에 붙여 넣어도 안전합니다. 만료 시각이 한참 지났기 때문에 디코더가 만료로 표시할 것입니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzg0NzIiLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3NjAwMDAwMDAsIm5iZiI6MTc2MDAwMDAwMCwiZXhwIjoxNzYwMDAzNjAwLCJyb2xlIjoiZWRpdG9yIn0.HxCK2fm8QsP48lcR1KiGqu3D-PkD-58YhLEc_dMBurc
header = {"alg":"HS256","typ":"JWT"}
payload = {"sub":"user_8472","iss":"https://auth.example.com",
"aud":"api.example.com","iat":1760000000,"nbf":1760000000,
"exp":1760003600,"role":"editor"}
signature = HMAC-SHA256(secret, base64url(header) + "." + base64url(payload))Base64URL은 암호화가 아니다
Base64URL(RFC 4648 5절)은 웹에서 안전하게 쓰도록 만든 Base64 변형입니다. + 대신 -, / 대신 _를 쓰고 끝의 = 패딩을 생략하므로, 토큰을 URL, 헤더, 쿠키에 이스케이프 없이 넣을 수 있습니다. JSON {"a":"?>>"}는 표준 Base64로 eyJhIjoiPz4+In0=, Base64URL로 eyJhIjoiPz4-In0입니다. 일반 Base64 디코더는 두 번째 형태에서 실패할 수 있어서, 문자를 되돌리고 패딩을 복원해야 합니다.
핵심은 Base64가 암호가 아니라 인코딩이라는 점입니다. 토큰을 가진 사람은 누구나 키 없이 헤더와 페이로드를 디코딩해서 읽을 수 있습니다. 서명은 무결성과 발급처만 증명합니다. 기밀성이 필요하면 암호화된 JWT(JWE, RFC 7516)를 쓰거나, 데이터는 서버에 두고 토큰에는 불투명한 참조값만 넣는 편이 낫습니다.
- 헤더: 'alg'는 서명 알고리즘, 'typ'은 보통 JWT, 'kid'는 사용한 키의 이름입니다.
- 페이로드: 클레임을 담은 JSON 객체이며 이름은 대소문자를 구분합니다.
- 서명: Base64URL로 인코딩한 바이너리입니다. HS256은 항상 32바이트, 즉 43자입니다.
알아야 할 등록 클레임
RFC 7519 4.1절은 일곱 가지 클레임 이름을 등록해 두었습니다. 명세상 모두 선택 사항이지만, 검증하는 쪽이 자기 애플리케이션에 필요한 것을 정해야 합니다. 시간 클레임은 NumericDate로, 1970-01-01T00:00:00Z 이후의 초이며 밀리초가 아닙니다. 밀리초를 넣으면 58,000년대까지 유효해 보이는 토큰이 됩니다.
| 클레임 | 의미 | 검증자가 확인할 것 |
|---|---|---|
| iss | 발급자: 토큰을 만든 주체 | 신뢰하는 발급자와 정확히 같은 문자열인지 |
| sub | 주체: 토큰이 가리키는 대상 | 사용자·클라이언트 식별자로 쓰며 발급자 안에서 고유 |
| aud | 수신자: 토큰이 향하는 대상 | 내 API의 식별자가 aud에 있는지, 다른 서비스용 토큰은 거부 |
| exp | 만료 시각 | 현재 시각이 exp 이전인지(60초 정도의 시계 오차 허용) |
| nbf | 이 시각 이전에는 무효 | 현재 시각이 nbf 이후인지 |
| iat | 발급 시각 | 참고용이거나, 자체 최대 수명보다 오래된 토큰을 거부하는 데 사용 |
| jti | JWT ID: 토큰 고유 식별자 | 재사용 공격 탐지나 폐기 목록에 사용 |
데모 토큰의 시간 읽기
데모 페이로드에서 iat와 nbf는 1760000000으로 2025-10-09T08:53:20Z이고, exp는 1760003600으로 2025-10-09T09:53:20Z입니다. 차이는 3,600초이므로 토큰 수명은 한 시간입니다. 60초의 시계 오차를 허용하는 검증자는 08:52:20Z부터 09:54:20Z까지 이 토큰을 받아들입니다. 지금은 한참 전에 만료되었기에 디코더가 만료로 표시하며, 상대 시간을 보여 주는 디코더라면 이 검사가 금방 끝납니다.
짧은 수명은 결함이 아니라 설계 선택입니다. 서명된 JWT는 검증자가 별도의 거부 목록을 확인하지 않는 한 exp 전에는 폐기할 수 없으므로, 액세스 토큰은 대개 몇 분에서 한 시간 정도로 짧게 두고 갱신 수단을 따로 둡니다.
디코딩은 검증이 아니다
디코딩은 점으로 나누어 앞의 두 부분을 Base64URL로 푸는 것입니다. 검증은 올바른 키와 알고리즘으로 서명을 다시 계산하고, 상수 시간으로 비교한 뒤 위의 클레임을 확인하는 것입니다. 이 사이트의 것을 포함한 디코더는 브라우저 안에서 로컬로 실행되며 토큰이 무엇을 주장하는지 보여 줄 뿐, 그 주장이 사실인지는 알려 주지 않습니다.
공격자는 "role":"admin"이나 다른 사람의 sub 같은 원하는 페이로드로 토큰을 만들 수 있고, 그 토큰은 완벽하게 디코딩됩니다. 진짜 토큰과 위조 토큰을 가르는 것은 공격자가 갖지 못한 키로 서버가 수행하는 서명 검증뿐입니다. 클라이언트에서 읽은 클레임으로 서버의 권한 결정을 내리지 마세요. 서버에서 먼저 검증해야 합니다.
alg none과 키 혼동 공격
고전적인 공격 두 가지는 헤더를 믿는 데서 나옵니다. 첫째, 공격자가 {"alg":"none"}(Base64URL로 eyJhbGciOiJub25lIn0)으로 바꾸고 서명을 지워, 끝에 점만 남은 토큰을 보냅니다. RFC 7519 6절은 이런 '비보호' JWT를 정의하고 있고, 헤더를 그대로 따르던 라이브러리는 이를 유효하다고 받아들였습니다. 해결책은 인증에 none을 절대 허용하지 않고 허용 알고리즘을 직접 지정하는 것입니다.
둘째, RS256(공개키로 확인하는 비대칭 서명)을 기대하는 서버가 헤더에 HS256이 적힌 토큰을 받습니다. 취약한 라이브러리는 공개되어 있는 서버의 공개키를 HMAC 비밀키로 취급합니다. 공격자는 그 공개키로 위조 토큰에 서명하고 검증이 통과합니다. 해결 원칙은 같습니다. 서버에서 알고리즘을 고정하고(algorithms: ["RS256"]), 키는 타입이 있는 키 객체로 넘기며, 토큰이 알고리즘을 고르게 두지 않습니다. RFC 8725(JWT 모범 사례)가 이와 관련 방어법을 정리합니다.
헤더와 관련된 위험도 같은 패턴입니다. kid 값은 자체 키 저장소에서 키를 고르는 데만 쓰고 파일 경로나 SQL 쿼리에 넣지 마세요. jku, jwk 같은 헤더는 공격자가 통제하는 키를 가리킬 수 있으므로, 발급자 URL의 고정된 허용 목록으로 제한하지 않는다면 무시하세요.
| 공격 | 공격자가 바꾸는 것 | 서버 측 방어 |
|---|---|---|
| alg none | 헤더 alg를 none으로, 서명 제거 | none 거부, 설정한 알고리즘만 허용 |
| RS256을 HS256으로 바꾸는 키 혼동 | 헤더 alg가 HS256이고 공개키로 서명 | 키별로 알고리즘 고정, 타입 있는 키 사용 |
| 약한 HS256 비밀키 | 짧거나 사전에 있는 비밀키를 오프라인으로 추측 | 256비트 이상의 무작위 비밀키 사용(RFC 7518 3.2절) |
| 잘못된 수신자·발급자 | 다른 서비스의 토큰을 재사용 | 모든 요청에서 iss와 aud 확인 |
| 헤더 키 주입 | kid, jku, jwk가 공격자 데이터를 가리킴 | 신뢰하는 저장소나 고정 허용 목록에서만 키 조회 |
페이로드에 넣으면 안 되는 것
페이로드는 토큰을 볼 수 있는 모든 사람이 읽을 수 있으니 엽서처럼 다루세요. 비밀번호, API 키 같은 비밀값, 카드 정보 전체, 주민등록번호 같은 신분 번호, 건강 정보, 그리고 브라우저 기록·프록시 로그·오류 보고서에 남아도 괜찮지 않은 개인정보는 넣지 마세요. 토큰은 로그, URL, 스크린샷으로 실제로 새어 나갑니다.
페이로드는 작게 유지하세요. 모든 요청이 함께 싣고 다니고, 헤더에는 크기 제한이 있습니다. 많은 서버가 8KB가 넘는 헤더를 거부하고 쿠키는 약 4KB로 제한됩니다. 역할 이름이나 권한 범위(scope)는 토큰에 넣어도 되지만, 사용자 프로필이나 200개짜리 권한 목록, 직렬화된 세션은 넣지 마세요.
디버깅용 로그에는 토큰 자체 대신 jti나 토큰의 해시를 남기세요. SHA-256 지문이면 이벤트를 연결하기에 충분하고, 로그가 쓸 수 있는 자격 증명이 되지도 않습니다.
안전한 확인·검증 체크리스트
확인할 때는 토큰을 점으로 나누고, 앞의 두 부분을 디코딩해 헤더의 알고리즘이 기대한 것인지 보고, exp, nbf, iat를 날짜로 바꿔 봅니다(단위는 초). 코드로 검증할 때는 유지보수되는 라이브러리를 쓰고, 허용 알고리즘을 명시적으로 넘기고, 키는 자체 설정에서 가져오고, exp를 필수로 두고, iss와 aud를 검증합니다. 시계 오차는 조금만 허용합니다. 비대칭 키라면 kid로 교체하고 키 세트를 갱신 주기와 함께 캐시하세요.
마지막으로 JWT가 적절한 도구인지도 따져 보세요. 단일 백엔드를 쓰는 일반적인 브라우저 세션이라면 HttpOnly 쿠키의 불투명 세션 ID가 즉시 폐기할 수 있고 읽을 수 있는 데이터도 싣지 않습니다. JWT가 제 역할을 하는 곳은 API 게이트웨이나 OpenID Connect처럼 별도 서비스들이 중앙 서버를 호출하지 않고 신원을 검증해야 할 때입니다.
서명 알고리즘 고르기
alg 헤더는 RFC 7518(EdDSA는 RFC 8037)에 정의된 알고리즘 계열의 이름입니다. 실제 결정은 대칭이냐 비대칭이냐입니다. HS256은 같은 비밀키로 서명하고 검증하므로 토큰을 검증하는 모든 서비스가 위조도 할 수 있습니다. RS256, ES256, EdDSA는 발급자가 개인키를 갖고 공개키를 배포하므로, 검증자는 토큰을 확인할 수는 있어도 만들 수는 없습니다.
| 알고리즘 | 종류 | 검증용 키 | 쓰는 경우 |
|---|---|---|---|
| HS256 | SHA-256 기반 HMAC | 256비트 이상의 공유 비밀키 | 한 서비스가 발급과 검증을 모두 하고 비밀키가 밖으로 나가지 않을 때 |
| RS256 | SHA-256 기반 RSA | 발급자의 공개키 | 여러 서비스가 검증할 때, 라이브러리 지원이 가장 넓음 |
| ES256 | P-256 ECDSA | 발급자의 공개키 | RS256과 같은 용도에 키와 서명이 훨씬 짧음 |
| EdDSA | Ed25519 | 발급자의 공개키 | 라이브러리가 지원한다면 현대적인 기본값 |
| none | 서명 없음 | 없음 | 인증에는 절대 쓰지 않음 |
코드에서 올바르게 검증하기
패턴은 어느 언어에서나 같습니다. 라이브러리에 토큰, 자체 설정에서 가져온 키, 허용하는 알고리즘의 정확한 목록, 필수로 요구하는 클레임을 넘기는 것입니다. 예시는 JavaScript의 jose 라이브러리를 썼습니다. 옵션 이름은 다른 곳에서 다르지만 검사 항목은 같습니다.
검증 오류는 어떤 검사가 실패했는지 알리지 않는 401 응답으로 처리하세요. 구체적인 메시지는 공격자가 탐색하는 데 도움이 됩니다. 이유는 서버에 기록하세요.
import { jwtVerify, createRemoteJWKSet } from "jose";
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const { payload } = await jwtVerify(token, jwks, {
algorithms: ["RS256"], // 고정값, 토큰에서 읽지 않음
issuer: "https://auth.example.com",
audience: "api.example.com",
clockTolerance: 60, // 허용하는 시계 오차(초)
});
// 서명, exp, nbf, iss, aud 검사 중 하나라도 실패하면 jwtVerify가 예외를 던짐토큰을 어디에 두고 어떻게 폐기할까
토큰은 Authorization: Bearer 헤더나 HttpOnly, Secure, SameSite 쿠키로 보내세요. 토큰을 localStorage에 저장하면 페이지의 어떤 스크립트도 읽을 수 있어서, 크로스 사이트 스크립팅 버그 하나가 모든 사용자의 세션을 유출합니다. HttpOnly 쿠키는 스크립트가 읽을 수 없지만 CSRF 방어가 필요하며, SameSite와 위조 방지 토큰이 이를 제공합니다.
서명된 JWT는 exp까지 유효하므로 로그아웃만으로는 무효화되지 않습니다. 보통의 설계는 5~15분짜리 짧은 액세스 토큰과, 서버가 폐기하고 회전할 수 있는 더 긴 리프레시 토큰의 조합입니다. 액세스 토큰을 즉시 폐기해야 한다면 jti 기반 거부 목록을 두고 추가 조회를 감수하세요. 그 단계라면 불투명 세션 ID가 더 간단한 경우가 많습니다.