JWT를 검색하면 “Claim 기반”, “Self-Contained”, “RFC 7519” 같은 단어가 먼저 나온다. 틀린 말은 아니지만 처음 보면 머리에 잘 안 들어온다.
이 글에서는 세부 스펙보다 큰 그림을 잡는 데 집중한다. JWT를 현실의 편지에 비유해서 설명하고, 마지막에 실제 코드로 확인한다.
JWT는 “누구나 읽을 수 있지만 위조할 수 없는 편지”다
JWT를 한 문장으로 줄이면 이렇다.
봉투 없이 보내는 편지. 누구나 읽을 수 있지만, 편지 끝에 찍힌 도장 덕분에 내용을 고치면 바로 들킨다.
등장인물을 정리하면 아래와 같다.
| 편지 | JWT |
|---|---|
| 편지 | JWT 토큰 |
| 발신인 | 토큰을 발급하는 서버 (로그인 처리하는 쪽) |
| 수신인 | 토큰을 받아 검증하는 서버 (API 서버) |
| 우체부 | 브라우저, 앱, 네트워크 등 토큰을 운반하는 모든 것 |
| 편지 끝의 도장 | 서명 (Signature) |
| 도장을 찍는 인장 | 시크릿 키 |
편지가 오가는 과정
- 발신인은 편지를 쓰고, 끝에 자기 인장으로 도장을 찍는다.
- 우체부는 편지를 옮기면서 몰래 읽을 수 있다. 봉투가 없기 때문이다. 하지만 인장이 없으니 내용을 고치고 도장을 다시 찍을 수는 없다.
- 수신인은 편지를 받아 도장이 진짜인지 확인하고, 진짜라면 내용을 믿는다.
- 우체부가 실수로 제3자에게 편지를 넘겨도, 그 사람 역시 읽을 수만 있고 고칠 수는 없다.
실제 웹 서비스에 대입하면 이렇게 된다.
- 로그인에 성공하면 서버가 “이 사람은 user_1이고 일반 회원이다” 라는 편지(JWT)를 써서 도장을 찍어 준다.
- 클라이언트는 API를 호출할 때마다 이 편지를
Authorization: Bearer <JWT>헤더에 실어 보낸다. - API 서버는 도장만 확인하면 되기 때문에 DB나 세션 저장소를 조회하지 않고도 “user_1의 요청” 임을 믿을 수 있다.
3번이 JWT의 가장 큰 장점이다. 편지 자체에 필요한 정보가 다 들어 있어서(이걸 Self-Contained 라고 부른다) 서버가 상태를 들고 있을 필요가 없다. 세션 방식과의 차이는 stateless와 stateful의 차이에 정리해 두었다.
편지의 구조 : header.payload.signature
JWT는 점(.)으로 구분된 세 부분으로 이루어진다.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEiLCJyb2xlIjoibWVtYmVyIiwiZXhwIjoxNzkwMDc1NjA3fQ.U8IN8TtmUI1Y17n7mq1X8Gb2CXkkYLN04BIJdQUWYw8
| 부분 | 편지로 치면 | 내용 |
|---|---|---|
| header | 편지 형식 안내 | 어떤 방식으로 도장을 찍었는지 ({"alg":"HS256"}) |
| payload | 본문 | 전달하려는 정보. 하나하나를 클레임(claim) 이라고 부른다 |
| signature | 도장 | header와 payload를 시크릿 키로 서명한 값 |
header와 payload는 암호화가 아니라 Base64URL 인코딩일 뿐이다. 그래서 키가 없어도 누구나 원래 내용을 볼 수 있다.
header, payload, signature = token.split(".")
Base64.urlsafe_decode64(header)
# => {"alg":"HS256"}
Base64.urlsafe_decode64(payload + "=" * ((4 - payload.size % 4) % 4))
# => {"sub":"user_1","role":"member","exp":1790075607}
payload에 자주 쓰는 클레임은 이미 이름이 정해져 있다.
| 클레임 | 의미 |
|---|---|
sub |
누구에 대한 토큰인지 (보통 유저 ID) |
exp |
만료 시각 (UNIX time) |
iat |
발급 시각 |
iss |
발급자 |
aud |
이 토큰을 받을 대상 |
직접 위조해 보기
편지 내용을 고치면 정말 들키는지 ruby-jwt로 확인해 보자.
require "jwt"
secret = "my-secret-key"
payload = { sub: "user_1", role: "member", exp: Time.now.to_i + 3600 }
token = JWT.encode(payload, secret, "HS256")
JWT.decode(token, secret, true, algorithm: "HS256")
# => [{"sub"=>"user_1", "role"=>"member", "exp"=>1790075607}, {"alg"=>"HS256"}]
이제 우체부가 몰래 role 을 admin 으로 바꿔 보았다고 하자. payload만 새로 만들고 도장(signature)은 원래 것을 그대로 붙인다.
fake_payload = Base64.urlsafe_encode64(
{ sub: "user_1", role: "admin", exp: payload[:exp] }.to_json, padding: false
)
fake = [header, fake_payload, signature].join(".")
JWT.decode(fake, secret, true, algorithm: "HS256")
# => JWT::VerificationError: Signature verification failed
본문이 바뀌었는데 도장은 옛날 본문 기준이라 검증에 실패한다. 인장(시크릿 키)이 없으면 새 본문에 맞는 도장을 찍을 방법이 없다.
인장을 누가 가지고 있나 : HS256과 RS256
여기서 한 가지 짚고 넘어가야 할 점이 있다. 도장을 확인하려면 무엇이 필요할까?
HS256 (대칭키)
도장을 찍는 인장과 확인하는 도구가 같다. 즉 수신인도 같은 인장을 가지고 있어야 한다.
- 발신인과 수신인이 같은 서버이거나, 서로 완전히 믿는 관계일 때 쓴다
- 수신인도 인장을 가지고 있으니 수신인은 편지를 위조할 수 있다
RS256 (비대칭키)
인장(비밀키)은 발신인만 가지고, 수신인에게는 “이 도장이 진짜인지 대조해 보는 견본”(공개키)만 나눠 준다.
rsa = OpenSSL::PKey::RSA.generate(2048)
token = JWT.encode(payload, rsa, "RS256") # 비밀키로 도장
JWT.decode(token, rsa.public_key, true, algorithm: "RS256")
# => 공개키로 검증 성공
JWT.encode(payload, rsa.public_key, "RS256")
# => ArgumentError: private key is needed # 공개키로는 도장을 못 찍는다
- 여러 서비스가 같은 토큰을 검증해야 할 때 쓴다 (Google, Firebase 등의 ID 토큰이 이 방식)
- 수신인은 확인만 할 수 있고 위조는 할 수 없다
“수신인도 위조할 수 없다” 는 RS256일 때만 성립한다는 점을 기억해 두자.
편지 비유로 보는 주의점
1. 본문에 비밀을 쓰면 안 된다
봉투가 없는 편지라서 우체부도 읽는다. 비밀번호, 개인정보 같은 값은 payload에 넣지 않는다. 내용 자체를 숨겨야 한다면 JWE(암호화된 JWT)를 따로 써야 한다.
2. 한 번 보낸 편지는 회수할 수 없다
서버가 상태를 들고 있지 않다는 장점은 반대로 이미 발급한 토큰을 무효로 만들기 어렵다는 단점이 된다. 세션이라면 서버에서 세션을 지우면 끝이지만, JWT는 만료될 때까지 계속 유효하다.
그래서 보통 이렇게 한다.
exp를 반드시 넣고, Access Token의 유효기간을 짧게(수 분 ~ 수십 분) 잡는다- 오래 쓰는 Refresh Token을 따로 발급해서 Access Token을 재발급한다
- 강제 로그아웃이 필요하면 무효화 목록(블랙리스트)을 두는데, 이 순간 stateless의 장점은 일부 포기하게 된다
old = JWT.encode({ sub: "user_1", exp: Time.now.to_i - 10 }, secret, "HS256")
JWT.decode(old, secret, true, algorithm: "HS256")
# => JWT::ExpiredSignature: Signature has expired
3. 편지를 주운 사람도 쓸 수 있다
Bearer 는 “소지자” 라는 뜻이다. 편지를 가진 사람이 곧 주인으로 취급된다. 도장이 진짜인 이상, 훔친 편지도 서버는 받아들인다.
- 반드시 HTTPS로만 주고받는다
- 브라우저에서는 저장 위치를 신중하게 고른다.
localStorage는 XSS에 약하고, 쿠키에 넣는다면HttpOnly,Secure,SameSite를 설정한다 (쿠키에 대해)
4. 편지 형식 안내를 그대로 믿으면 안 된다
header의 alg 는 편지를 보낸 쪽이 적는 값이다. 여기에 none(도장 없음)을 적어 보내는 공격이 실제로 있었다. 검증할 때는 위 예제처럼 허용할 알고리즘을 서버 쪽에서 명시한다 (algorithm: "HS256").
정리
- JWT는 누구나 읽을 수 있지만 위조할 수 없는 편지다
- 본문(payload)은 인코딩일 뿐 암호화가 아니다. 비밀은 넣지 않는다
- 위조를 막는 건 서명(도장)이고, HS256은 수신인도 인장을 가지며 RS256은 발신인만 인장을 가진다
- 서버가 상태를 들고 있지 않아도 되는 대신, 보낸 편지를 회수하기 어렵다. 짧은 만료 시간 + Refresh Token으로 보완한다