안내 보완 · 2026년 10월 14일: 1시간 계산 예시와 내장 샘플을 구분하고, UTC·한국시간 및 입력 시각 기준을 덧붙였습니다.
서버가 401 Unauthorized를 던집니다. 토큰이 만료된 걸까요, 권한이 모자란 걸까요. 샘플 토큰의 exp는 만료 여부를 살펴볼 단서지만, 401의 원인을 혼자서 증명하지는 못합니다. 실제 인증정보를 온라인 도구에 붙여넣지 않고 구조를 이해하는 법부터 정리합니다.
서명형 JWT의 payload는 암호화가 아니라 인코딩이다
이 글과 도구가 다루는 일반적인 서명형 JWT는 점(.)으로 이어진 세 조각입니다. header.payload.signature 순서이며, header와 payload는 base64url로 인코딩돼 있습니다. 암호화된 JWT는 형식이 다를 수 있습니다.
흔한 오해가 여기서 생깁니다. 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년부터의 경과 초입니다.
단위는 밀리초가 아니라 초입니다(RFC 7519의 NumericDate). JavaScript의 new Date()에 넣을 때는 1,000을 곱해야 합니다. 아래 1시간 예시는 내장 샘플과 별개인 산술 예시입니다.
iat: 1700000000→2023-11-14T22:13:20Zexp: 1700003600→2023-11-14T23:13:20Z- 차이는 3,600초입니다. 서명이나 실제 사용 가능 기간을 입증하지는 않습니다.
실제 샘플로 UTC와 한국시간 대조하기
- 한국어 JWT 디코더를 열어 샘플을 누릅니다. 실제 계정의 토큰이나 붙여넣기는 필요 없습니다.
- Payload의 시간값은
iat: 1718500000,exp: 1900000000입니다. 위의 1시간 예시와 다릅니다. - 표시된 UTC 시각과 다음 한국시간을 대조합니다. 도구는 UTC를 표시하며, 한국시간은 이 글에서 따로 환산한 값입니다.
| 항목 | 도구의 UTC | 한국시간 (UTC+9) |
|---|---|---|
iat | 2024-06-16 01:06:40 | 2024-06-16 10:06:40 |
exp | 2030-03-17 17:46:40 | 2030-03-18 02:46:40 |
도구의 ISO 문자열 끝에 붙는 .000Z에서 Z는 UTC입니다. 만료 시각은 한국시간으로 다음 날이므로, 서버 로그와 비교할 때 날짜만 보고 불일치라고 판단하지 마세요. 샘플의 두 값 차이는 181,500,000초입니다. 공개 시연 값이지 권장 토큰 수명이 아닙니다.
다음 코드는 공개 샘플의 숫자만 계산합니다. 실제 토큰을 입력하거나 서버에 보내지 않습니다.
const expSeconds = 1900000000;
const utc = new Date(expSeconds * 1000).toISOString();
const kst = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul', dateStyle: 'short', timeStyle: 'medium',
hourCycle: 'h23'
}).format(new Date(expSeconds * 1000));
console.log({ utc, kst });
utc는 2030-03-17T17:46:40.000Z이고, kst는 2030년 3월 18일 02:46:40을 나타냅니다. 구두점·공백은 실행 환경에 따라 다를 수 있습니다. 1,000을 곱하지 않은 new Date(1900000000)은 1970년 날짜가 되므로 같은 값이 아닙니다.
배지는 실시간 카운트다운이 아니다
결과의 입력 시각 기준을 확인하세요. 이 도구는 입력이 바뀔 때 기기의 시각으로 비교합니다. 같은 토큰을 그대로 둔다고 만료 순간에 배지가 자동으로 갱신되는 것은 아닙니다. 공개 샘플을 새로 비교하려면 비우기 → 샘플을 누르고 기준 시각이 바뀌었는지 봅니다.
2026년 10월 14일 확인 당시에는 표기된 만료 시각 전이 나왔습니다. 만료 시각에 도달하면 비교 결과가 달라져야 하지만, 어느 쪽도 미검증 안내를 없애지 않습니다. 기기 시계와 서버의 검증 시계·허용 오차가 다를 수도 있습니다.
샘플에는 iss, aud, nbf가 없습니다. 따라서 발급자·대상·활성 시작 조건을 통과했다는 뜻이 아닙니다. 실제 401 원인은 서버의 검증 결과와 조직에서 승인한 로컬 절차로 확인하고, 이 공개 시연 토큰을 API에 보내 시험하지 마세요.
디코드는 검증이 아니다
가장 중요한 구분입니다. 토큰을 디코드해 내용을 읽는 것과, 그 토큰이 진짜인지 검증하는 것은 다릅니다.
세 번째 조각인 서명(signature)은 발급자가 서명키로 만든 값입니다. 위조되지 않았는지 확인하려면 발급자의 알고리즘과 신뢰할 수 있는 비밀키 또는 공개키로 서명을 검증해야 합니다(RFC 7515). 이 디코더는 키를 받지도 보관하지도 않으므로 검증을 하지 않습니다. 따라서 디코드해서 보이는 payload는 “서버가 서명을 검증하기 전까지는 신뢰할 수 없는 값”으로 다뤄야 합니다. payload의 role이 admin이라고 적혀 있어도, 검증 없이 그 말을 믿으면 안 됩니다.
그 운영 토큰, 어디에 붙여넣고 있나요
401을 디버깅하려고 운영 환경의 액세스 토큰을 온라인 디코더에 붙여넣지 마세요. bearer 토큰은 가진 사람이 사용할 수 있는 인증정보이므로 노출 자체가 위험합니다(RFC 6750).
PiPi Worlds JWT 디코더의 디코드 처리는 브라우저에서 실행되지만, 실제 운영 토큰의 안전을 보증하는 기능은 아닙니다. 내장 샘플이나 시험용 토큰으로 구조를 살펴보고, 운영 장애는 조직에서 승인한 로컬 디버깅 절차와 서버 검증 결과로 확인하세요. 시험용 payload를 더 보기 좋게 펼치려면 JSON 포맷터를, base64url 인코딩을 살펴보려면 Base64 도구를 사용할 수 있습니다.