로그인 API가 응답으로 내려주는 eyJhbGci... 형태의 긴 문자열, 다들 한 번쯤 본 적 있을 겁니다. 이 글에서는 JWT(JSON Web Token)가 어떤 구조로 이루어져 있는지, 왜 "암호화된 것이 아닌지", 그리고 서명 검증이 실제로 무엇을 보장하는지 짚어봅니다.
JWT는 점(.)으로 구분된 3부분
JWT는 header.payload.signature 형태로, 마침표 두 개를 기준으로 세 부분이 이어붙은 문자열입니다. 각 부분은 JSON을 Base64URL로 인코딩한 값입니다. Base64URL은 표준 Base64와 거의 같지만, URL에 그대로 써도 안전하도록 +와 /를 각각 -와 _로 바꾸고 끝의 = 패딩을 생략한 변형입니다. 예를 들어 다음과 같은 토큰이 있다면:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6ImRldmVsb3BrIiwiZXhwIjoxNzUyMDAwMDAwfQ.4f3c2a1e...
첫 번째 부분을 디코딩하면 {"alg":"HS256","typ":"JWT"}, 두 번째 부분을 디코딩하면 {"sub":"1234","name":"developk","exp":1752000000}가 나옵니다. 세 번째 부분은 사람이 읽을 수 있는 JSON이 아니라, 앞의 두 부분을 특정 키로 서명한 바이트값입니다.
Header와 Payload에 담기는 정보
Header에는 보통 두 필드만 들어갑니다. alg는 서명에 사용한 알고리즘(예: HS256, RS256), typ는 토큰 타입(대개 JWT)을 나타냅니다. Payload에는 "클레임(claim)"이라 불리는 데이터가 들어가는데, 표준으로 정해진 것도 있고 서비스가 임의로 추가하는 것도 있습니다. 자주 보이는 표준 클레임은 다음과 같습니다.
sub— subject, 토큰이 가리키는 사용자 식별자iat— issued at, 토큰 발급 시각(유닉스 타임스탬프)exp— expiration, 토큰 만료 시각nbf— not before, 이 시각 이전에는 유효하지 않음
여기에 role, email처럼 서비스마다 필요한 커스텀 클레임을 자유롭게 추가할 수 있습니다.
왜 암호화가 아닌가 — Base64는 누구나 풀 수 있다
가장 많이 오해하는 부분입니다. Base64URL 인코딩은 데이터를 안전한 텍스트 형태로 "표현"하는 방식일 뿐, 숨기는 방식이 아닙니다. 키도 필요 없이 누구나 즉시 원래 JSON으로 되돌릴 수 있습니다. 따라서 Payload에 비밀번호, 카드번호, 세션 시크릿 같은 민감한 정보를 넣으면 안 됩니다. 토큰을 가진 사람(또는 네트워크를 가로챈 사람)이라면 누구나 그 내용을 읽을 수 있기 때문입니다.
그렇다면 JWT는 무엇을 보장할까요? 바로 무결성(integrity)입니다. Signature 부분이 Header와 Payload가 발급 이후 변조되지 않았음을 증명합니다. 즉 "이 내용을 볼 수 없게 감춘다"가 아니라 "이 내용을 서버 몰래 바꿀 수 없게 막는다"가 JWT 서명의 역할입니다.
HS256 서명 검증은 어떻게 동작하나
HS256(HMAC-SHA256)은 서버와 클라이언트(또는 서버끼리)가 같은 비밀키(secret)를 공유하는 대칭키 방식입니다. 토큰을 발급할 때 서버는 base64url(header) + "." + base64url(payload) 문자열을 비밀키로 HMAC-SHA256 서명해 세 번째 부분으로 붙입니다. 검증할 때는 받은 토큰의 Header·Payload를 같은 방식으로 다시 서명해보고, 그 결과가 토큰에 들어있는 Signature와 정확히 일치하는지 비교합니다. 비밀키를 모르는 사람은 Payload 내용을 바꾼 뒤 유효한 서명을 새로 만들어낼 수 없기 때문에, 서명이 일치한다는 것은 곧 "이 토큰이 비밀키를 가진 발급자로부터 나왔고, 발급 이후 변경되지 않았다"는 뜻이 됩니다. RS256처럼 공개키/개인키를 쓰는 비대칭 알고리즘도 있지만, 원리는 같습니다 — 서명 생성과 검증에 서로 다른 키를 쓴다는 점만 다릅니다.
흔한 실수
실무에서 자주 보이는 실수 몇 가지를 정리하면 다음과 같습니다.
- Payload에 비밀번호나 API 키 같은 민감 정보를 넣는 것 — 누구나 디코딩 가능
- 서명 검증 없이 Payload만 믿고 사용하는 것 — 클라이언트가 토큰을 조작해도 서버가 눈치채지 못함
alg: none을 허용하는 구현 — 서명 없이도 유효 토큰처럼 통과되는 취약점exp없이 영구 유효한 토큰을 발급하는 것 — 탈취 시 만료가 없어 위험이 커짐
토큰을 직접 붙여넣어 Header와 Payload 내용을 확인하고, HS256 시크릿이 있다면 서명까지 즉시 검증해보고 싶다면 아래 도구로 바로 확인해보세요.
표준 등록 클레임 (RFC 7519)
JWT Payload에 쓰이는 표준 클레임입니다. 전부 선택 사항이지만, 라이브러리 대부분이 exp·nbf를 자동 검증합니다.
| 클레임 | 이름 | 의미 |
|---|---|---|
| iss | Issuer | 토큰 발급자 |
| sub | Subject | 토큰 주체 (보통 사용자 ID) |
| aud | Audience | 토큰 수신 대상 |
| exp | Expiration Time | 만료 시각 (유닉스 타임스탬프, 초) |
| nbf | Not Before | 이 시각 이전에는 무효 |
| iat | Issued At | 발급 시각 |
| jti | JWT ID | 토큰 고유 식별자 (재사용 방지) |
주요 서명 알고리즘 (alg 헤더)
| alg | 방식 | 키 유형 |
|---|---|---|
| HS256 | HMAC + SHA-256 | 대칭키 (공유 비밀) |
| HS384 / HS512 | HMAC + SHA-384/512 | 대칭키 |
| RS256 | RSA + SHA-256 | 비대칭키 (개인/공개키) |
| ES256 | ECDSA + SHA-256 | 비대칭키 (타원곡선) |
| none | 서명 없음 | — (절대 허용 금지) |
⚠️ alg: none은 서명을 생략하는 값으로, 서버가 이를 허용하면 누구나 위조 토큰을 만들 수 있습니다. 검증 시 허용 알고리즘을 반드시 화이트리스트로 고정하세요.
🪪 이 작업, 도구로 바로 해보세요
JWT 토큰 구조 이해하고 디코딩하는 방법 — Header·Payload·Signature 3부분 구조 설명