서버가 401 Unauthorized를 던집니다. 토큰이 만료된 걸까요, 권한이 모자란 걸까요. 답은 토큰 안에 적혀 있는데, 길고 의미 없어 보이는 문자열이라 그냥 다시 로그인하고 넘어가기 쉽습니다. 그 문자열을 열어 보는 법부터 정리해 봅니다.
JWT는 암호화가 아니라 인코딩이다
JSON Web Token은 점(.)으로 이어진 세 조각입니다. header.payload.signature 순서로, 각 조각은 base64url로 인코딩돼 있습니다.
흔한 오해가 여기서 생깁니다. base64url은 암호화가 아니라 단순 인코딩이라, 비밀키 없이도 누구나 되돌려 읽을 수 있습니다. 즉 payload에 담긴 사용자 ID나 권한은 토큰을 가진 사람 누구에게나 보입니다. 비밀번호나 민감 정보를 payload에 넣으면 안 되는 이유입니다.
payload에 들어가는 클레임 읽기
payload 안의 각 항목을 클레임(claim)이라고 부릅니다. RFC 7519가 표준 클레임을 정의해 둬서, 이름만 알면 의미가 바로 읽힙니다.
| 클레임 | 의미 |
|---|---|
iss | 발급자 (issuer) |
sub | 토큰 주체 (subject, 보통 사용자 ID) |
aud | 대상 (audience) |
exp | 만료 시각 (expiration) |
iat | 발급 시각 (issued at) |
nbf | 활성 시작 시각 (not before) |
jti | 토큰 고유 ID |
exp, iat, nbf 세 개가 시간 클레임이고, 디버깅에서 가장 자주 보게 됩니다.
만료 확인: exp는 Unix epoch라 그냥 읽기 어렵다
exp가 만료 시각이긴 한데, 값이 1700003600 같은 숫자입니다. 사람이 읽으라고 만든 게 아니라 Unix epoch, 즉 1970년부터의 경과 초입니다.
그래서 도구가 환산을 대신합니다. PiPi Worlds JWT 디코더에 토큰을 넣으면 payload와 함께 시간 클레임이 ISO 형식으로 풀립니다. 예를 들어 iat: 1700000000은 2023-11-14T22:13:20Z, exp: 1700003600은 2023-11-14T23:13:20Z로 환산되고, 둘의 차이로 이 토큰의 유효 기간이 1시간임을 알 수 있습니다. 만료 시각이 현재보다 과거면 만료 배지가 붙습니다. 401이 떴을 때 이 배지 하나로 “만료라 갱신이 필요한지”가 바로 갈립니다.
디코드는 검증이 아니다
가장 중요한 구분입니다. 토큰을 디코드해 내용을 읽는 것과, 그 토큰이 진짜인지 검증하는 것은 다릅니다.
세 번째 조각인 서명(signature)은 발급자가 비밀키로 만든 값입니다. 위조되지 않았는지 확인하려면 같은 비밀키나 공개키로 서명을 다시 계산해 맞춰 봐야 합니다(RFC 7515). 디코더는 키를 받지도 보관하지도 않으므로 검증을 하지 않습니다. 따라서 디코드해서 보이는 payload는 “서버가 서명을 검증하기 전까지는 신뢰할 수 없는 값”으로 다뤄야 합니다. payload의 role이 admin이라고 적혀 있어도, 검증 없이 그 말을 믿으면 안 됩니다.
그 운영 토큰, 어디에 붙여넣고 있나요
여기서 보안 사고가 자주 납니다. 401을 디버깅하려고 운영 환경의 액세스 토큰을 아무 온라인 디코더에나 붙여넣는 경우입니다. 토큰 자체가 인증 수단인데, 입력값을 서버로 전송하는 사이트라면 그 순간 토큰이 제3자에게 넘어갑니다.
PiPi Worlds JWT 디코더는 디코딩이 전부 브라우저 안에서 실행됩니다. 토큰은 전송·기록·저장되지 않으므로 운영 토큰도 안심하고 살펴볼 수 있습니다. payload가 JSON이라 구조를 더 보기 좋게 펼치고 싶다면 JSON 포맷터를, 토큰을 이루는 base64url 인코딩 자체가 궁금하다면 Base64 도구를 함께 쓰면 됩니다.