JWT 디코딩 방법: 구조부터 만료시간까지

JWT(JSON Web Token) 문자열은 암호화된 것이 아니라 인코딩된 것입니다. 점(.)으로 구분된 세 부분 중 앞의 두 개(header·payload)는 base64url로 인코딩된 JSON 텍스트일 뿐이라, 별도의 비밀키 없이도 디코드만 하면 내용을 그대로 읽을 수 있습니다.
이 글은 그 구조를 직접 눈으로 확인하는 방법과, exp·iat 같은 시간 값이 어떤 숫자로 저장되고 어떻게 날짜로 바뀌는지를 정리합니다.
요약 ① JWT는 header.payload.signature 세 부분이 점(.)으로 이어진 문자열이며, header와 payload는 각각 base64url로 인코딩된 JSON입니다. ② base64url 디코드는 암호화를 해제하는 것이 아니라 인코딩을 되돌리는 것뿐이라, 비밀키 없이도 누구나 header·payload 내용을 읽을 수 있습니다. ③ exp·iat 같은 시간 값은 1970-01-01 00:00:00(UTC)부터 지난 초(秒)를 나타내는 유닉스 타임스탬프이며, 1000을 곱해야 자바스크립트의 밀리초 단위 날짜가 됩니다.
언제 이 문제를 마주치나요
로그인 API가 돌려준 응답에 access_token 같은 필드로 긴 문자열 하나만 들어 있는 경우가 흔합니다. eyJhbGci... 로 시작하는 이 문자열은 겉보기에 알아볼 수 없는 암호처럼 보여, 내용을 확인할 방법이 없다고 오해하기 쉽습니다.
실제로는 이 문자열 안에 사용자 식별자(sub)나 권한 정보, 토큰이 언제 발급됐고(iat) 언제 만료되는지(exp) 같은 값이 그대로 들어 있습니다. "로그인이 자꾸 풀린다"거나 "권한이 이상하게 적용된다" 같은 문제를 살펴볼 때, 토큰 안의 값을 직접 열어봐야 원인을 좁힐 수 있습니다.
도구 없이 직접 디코드하는 방법
JWT는 header.payload.signature 세 부분이 점(.) 하나로 구분된 하나의 문자열입니다. 각 부분이 무엇을 담고 어떻게 인코딩되어 있는지는 아래와 같이 정리됩니다.
| 부분 | 담긴 내용 | 인코딩 | JSON으로 바로 파싱되나요 |
|---|---|---|---|
| Header | 토큰 타입(typ)과 서명 알고리즘(alg) | base64url | 예 |
| Payload | sub·iat·exp 같은 클레임(claims) | base64url | 예 |
| Signature | header+payload를 비밀키로 서명한 결과값 | base64url | 아니오(JSON 형식이 아닌 바이트값) |
Signature만 JSON으로 파싱되지 않는 이유는 그 값이 애초에 JSON 텍스트가 아니라, 서명 알고리즘이 만들어낸 순수한 바이트 값이기 때문입니다. base64url로 인코딩되어 있다는 점은 header·payload와 같지만, 디코드해도 사람이 읽을 수 있는 텍스트가 나오지 않습니다.
base64url은 표준 base64와 살짝 다릅니다. URL이나 파일 경로에 그대로 쓰일 수 있도록 "+"는 "-"로, "/"는 "_"로 바꾸고, 끝의 "=" 패딩도 보통 생략합니다. 그래서 디코드하려면 이 치환을 먼저 되돌려야 합니다.
브라우저 개발자 도구(F12)의 콘솔에서 아래 코드를 실행하면 별도 도구 없이도 header와 payload를 직접 확인할 수 있습니다.
function base64UrlDecode(str) {
str = str.replace(/-/g, "+").replace(/_/g, "/");
while (str.length % 4) str += "=";
const binary = atob(str);
return decodeURIComponent(
binary.split("").map(c => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")).join("")
);
}
const [header, payload] = token.split(".");
console.log(JSON.parse(base64UrlDecode(header)));
console.log(JSON.parse(base64UrlDecode(payload)));
decodeURIComponent와 charCodeAt을 거치는 이유는 atob() 결과가 UTF-8 바이트를 그대로 문자로 담고 있어서입니다. payload에 한글 이름 같은 비ASCII 문자가 들어 있으면 atob() 결과를 바로 JSON.parse할 때 깨진 문자가 나올 수 있습니다.
payload를 얻었다면 exp·iat 값도 직접 날짜로 바꿀 수 있습니다. 두 값 모두 초 단위이므로 1000을 곱해 밀리초로 만든 뒤 Date 객체에 넣으면 됩니다.
new Date(payload.exp * 1000).toLocaleString();
저희 도구로 확인하기.
브라우저에서만 디코드되며 서버로 전송되지 않습니다. 서명 검증은 하지 않습니다.

헷갈리기 쉬운 지점
디코딩과 서명 검증은 서로 다른 작업입니다. 아래 표로 구분됩니다.
| 구분 | 디코딩 | 서명 검증 |
|---|---|---|
| 하는 일 | base64url을 되돌려 JSON으로 읽기 | secret/public key로 서명을 재계산해 비교 |
| 필요한 것 | 토큰 문자열 하나만 | 서명에 쓰인 비밀키 또는 공개키 |
| 알 수 있는 것 | header·payload에 적힌 내용 | 그 내용이 발급 이후 변조되지 않았는지 |
즉 디코딩은 "내용을 읽는 것"이고 서명 검증은 "그 내용이 변조되지 않았는지 확인하는 것"입니다. 두 작업은 필요한 재료도 목적도 다르며, header·payload를 읽었다고 해서 서명까지 확인된 것은 아닙니다.
exp·iat·nbf·auth_time은 모두 초 단위 유닉스 타임스탬프입니다. 자바스크립트의 Date는 밀리초를 기준으로 하므로, 1000을 곱하지 않고 값을 그대로 new Date()에 넣으면 1970년 1월 1일 근처의 엉뚱한 날짜가 나옵니다.
모든 토큰에 iat·exp·nbf·auth_time이 다 들어 있는 것은 아닙니다. 발급자가 필요한 클레임만 선택적으로 담기 때문에, exp가 없는 토큰이라고 해서 그 자체로 오류인 것은 아닙니다.
정리
- JWT는 header.payload.signature 세 부분이 점으로 이어진 문자열이고, header·payload만 base64url로 인코딩된 JSON입니다.
- signature는 JSON이 아니라 서명 알고리즘이 만든 바이트값이라 디코드해도 텍스트로 읽히지 않습니다.
- base64url 디코드는 인코딩을 되돌리는 것뿐이라 비밀키 없이 누구나 header·payload를 읽을 수 있고, 이는 암호화가 아닙니다.
- exp·iat 같은 시간 값은 초 단위 유닉스 타임스탬프이므로 1000을 곱해야 자바스크립트 날짜가 됩니다.
- 디코딩과 서명 검증은 서로 다른 작업이며, 토큰의 위·변조 여부는 디코딩만으로는 확인할 수 없습니다.
브라우저에서만 디코드되며 서버로 전송되지 않습니다. 서명 검증은 하지 않습니다.
자주 묻는 질문
signature도 디코드해서 검증할 수 있나요?
아니요. signature는 header와 payload를 합쳐 비밀키로 서명한 바이트값이라 JSON 형식이 아니며, base64url 디코드를 해도 의미 있는 텍스트가 나오지 않습니다. 서명이 유효한지 확인하려면 발급 시 쓰인 비밀키(또는 공개키)로 서명을 다시 계산해 비교하는 별도의 검증 절차가 필요합니다.
exp 값이 없는 토큰은 어떻게 되나요?
exp 클레임 자체가 없으면 만료 여부를 판단할 기준이 없다는 뜻입니다. exp가 있을 때만 그 값을 현재 시각과 비교해 유효함·만료됨을 표시할 수 있고, exp가 없는 토큰은 이 비교 대상에서 빠집니다.
입력한 토큰이 서버로 전송되나요?
아니요. header·payload를 JSON으로 바꾸는 디코드 과정은 전부 브라우저 안에서 처리되며, 입력한 토큰 문자열은 서버로 전송되거나 저장되지 않습니다. 다만 payload는 애초에 암호화되어 있지 않으므로, 화면을 캡처하거나 공유하면 그 안의 내용도 함께 노출됩니다.
iat·nbf·auth_time은 각각 무엇을 뜻하나요?
iat(issued at)은 토큰이 발급된 시각, nbf(not before)는 그 시각 이전에는 토큰이 유효하지 않다는 활성 시작 시각입니다. auth_time은 사용자가 실제로 인증(로그인)을 완료한 시각을 나타내는 값으로, 로그인 시점과 토큰 발급 시점이 다를 수 있는 구조에서 함께 쓰입니다.
