JSON Web Token에 실제로 담기는 것

JWT 내부의 헤더와 페이로드와 서명을 설명하고, 표준 클레임을 정리하며, 토큰을 Base64url로 디코딩하는 것이 왜 검증과 다른지 설명합니다.

JSON Web Token(JWT)은 마침표로 이어 붙인 세 덩어리의 텍스트입니다. 각 덩어리는 Base64url로 인코딩되어 있고 앞의 두 덩어리는 그냥 JSON입니다. 토큰을 가진 사람은 누구나 그 안의 내용을 읽을 수 있습니다. 일반적인 JWT에서 암호화된 것은 아무것도 없습니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9      <- header
eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBbGV4In0   <- payload
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFW     <- signature

마침표로 이어 붙이면 요청에 붙여 넣는 그 토큰이 됩니다. 앞의 두 부분을 디코딩하면 평범한 JSON이 나옵니다.

{ "alg": "HS256", "typ": "JWT" }
{ "sub": "12345", "name": "Alex" }

실제 페이로드에는 대개 타임스탬프와 대상 서비스도 함께 들어 있습니다.

{
  "iss": "https://auth.example.com",
  "sub": "12345",
  "aud": "api.example.com",
  "iat": 1756000000,
  "exp": 1756003600,
  "roles": ["editor"]
}

헤더

헤더는 토큰이 어떻게 서명되었는지를 설명합니다. 가장 중요한 필드는 두 개입니다.

  • alg는 서명 알고리즘의 이름입니다. 예를 들어 HS256(공유 비밀값을 쓰는 SHA-256 기반 HMAC)이나 RS256(개인 키로 서명하고 공개 키로 확인하는 SHA-256 기반 RSA 서명)이 있습니다.
  • kid는 키 ID로 선택 사항이며, 여러 키 중 어느 것이 쓰였는지를 받는 쪽에 알려줍니다. 덕분에 기존 토큰을 깨뜨리지 않고도 키를 교체할 수 있습니다.

typ은 보통 그냥 JWT이며 보안상의 의미는 없습니다.

페이로드와 그 클레임

페이로드는 클레임으로 이루어진 JSON 객체입니다. 클레임은 토큰의 주체에 관한 진술을 가리키는 명세의 용어일 뿐입니다. 일부 클레임 이름은 표준에 등록되어 있으며 모두 짧습니다.

클레임이름담고 있는 것
iss발급자토큰을 만든 주체이며 보통 URL이나 서비스 이름
sub주체토큰이 누구에 관한 것인지이며 보통 변하지 않는 사용자 ID
aud대상토큰이 어디에 쓰이도록 의도되었는지. 서비스가 다른 서비스용 토큰을 거부할 수 있게 해줍니다
exp만료 시각이 시각 이후로는 토큰을 반드시 거부해야 하는 순간
nbf유효 시작 시각이 시각 이전에는 토큰을 반드시 거부해야 하는 순간
iat발급 시각토큰이 만들어진 시점
jtiJWT ID고유 식별자이며 개별 토큰을 추적하거나 차단하는 데 유용합니다

exp, nbf, iat는 모두 NumericDate 값이며, 1970년 1월 1일 UTC 이후의 초를 뜻합니다. 밀리초가 아니라 초입니다. 어떤 타임스탬프가 57,000년쯤의 날짜로 디코딩된다면 밀리초를 읽고 있는 것이므로 1000으로 나누어야 합니다.

페이로드의 나머지는 발급자가 넣어둔 무엇이든 될 수 있습니다. 역할, 권한, 이메일 주소, 테넌트 ID 같은 것들입니다. 사설 클레임 이름이 언젠가 등록된 이름과 충돌할 수 있으므로, 발급자들은 흔히 URL 형태의 접두사로 자기 클레임에 네임스페이스를 붙입니다.

서명

서명은 헤더에 적힌 알고리즘과 키를 사용해, 앞의 두 부분을 점으로 이어 붙인 정확한 텍스트에 대해 계산됩니다. 헤더나 페이로드의 문자 하나만 바꿔도 서명이 더 이상 맞지 않습니다.

HS256에서는 같은 비밀값이 서명도 하고 검증도 하므로, 토큰을 확인할 수 있는 사람은 토큰을 만들어낼 수도 있습니다. RS256이나 ES256에서는 개인 키가 서명하고 공개 키가 검증하므로, 신원 제공자가 발급한 토큰을 여러 서비스가 비밀값을 하나도 갖지 않은 채 검증할 수 있습니다.

Base64url은 암호화가 아닙니다

Base64url은 Base64와 같은 발상이며 두 가지가 다릅니다. +/ 대신 -_를 써서 결과가 URL에서 안전하도록 하고, 끝에 붙는 = 패딩은 대개 제거합니다. 이것은 인코딩, 즉 바이트를 텍스트로 적는 되돌릴 수 있는 방식입니다. 기밀성은 전혀 제공하지 않습니다.

그러므로 JWT 페이로드에 민감한 것을 절대 넣지 마십시오. 비밀번호도, 카드 번호도, 사용자에 관한 내부 메모도 안 됩니다. 토큰을 가진 사람과 로그 파일이나 브라우저 저장소에서 그것을 읽어낸 사람이 모든 필드를 볼 수 있다고 가정하십시오.

페이로드를 실제로 암호화하는 JWE라는 별도의 형식이 있으며, 이것은 부분이 세 개가 아니라 다섯 개입니다. 토큰의 부분이 세 개라면 그것은 서명된 것이고 읽을 수 있는 것입니다.

디코딩은 검증이 아닙니다

여기가 핵심입니다. 디코더는 토큰이 무엇이라고 말하는지를 보여줍니다. 그 토큰이 진짜인지는 알려주지 않습니다. 검증은 별개의 단계이며 키가 필요합니다.

토큰을 실제로 신뢰하려면 서버는 다음을 해야 합니다.

  1. 올바른 키로 서명을 확인합니다.
  2. 토큰 안의 alg 필드를 믿지 말고 서버가 기대하는 알고리즘을 강제합니다. 이 단계를 건너뛰는 데서 두 가지 고전적인 공격이 나옵니다. algnone으로 설정하고 서명을 떼어내는 방법, 그리고 어떤 서비스의 RSA 공개 키를 HMAC 비밀값으로 사용해 RS256 검증기가 HS256을 실행하도록 속이는 방법입니다.
  3. exp를, 그리고 있다면 nbf도 확인하되 약간의 시계 오차를 허용합니다.
  4. issaud가 이 서비스가 기대하는 값과 맞는지 확인합니다.

그 뒤에야 클레임이 의미를 갖습니다. 그전까지 페이로드는 네트워크로 도착한 검증되지 않은 문자열일 뿐입니다.

실무적인 참고 사항

  • 토큰은 보통 HTTP 헤더에 Authorization: Bearer <token> 형태로 실려 가므로 페이로드를 작게 유지하십시오. 클레임을 하나 추가할 때마다 모든 요청에 그것이 함께 실립니다.
  • 서명된 토큰은 만료될 때까지 유효합니다. 이를 취소하는 내장 방법이 없으며, 액세스 토큰의 수명이 흔히 몇 분 단위로 짧고 새 토큰을 받기 위해 별도의 리프레시 토큰을 쓰는 이유가 이것입니다.
  • 토큰이 잘못된 형식처럼 보이면 먼저 점의 개수를 세십시오. 점 두 개에 부분 세 개면 정상적인 서명 JWT입니다. 터미널에서 복사하다 잘린 토큰은 "invalid signature"의 아주 흔한 원인입니다.
  • 디버깅하면서 토큰을 디코딩해 만료 시각이나 주체나 역할을 읽는 일은 전혀 위험하지 않고 어떤 비밀값도 필요하지 않습니다. 페이로드는 바로 그러라고 있는 것입니다.