JavaScript에서 JWT를 디코딩할 때 제가 가장 먼저 구분하는 것은 읽을 수 있다는 것과 신뢰할 수 있다는 것은 전혀 다른 상태라는 점입니다. JWT의 header와 payload는 일반적인 서명형 JWT라면 Base64URL로 인코딩되어 있어서 secret이 없어도 읽을 수 있습니다. 하지만 JSON이 정상적으로 보인다고 해서 서명이 맞거나, issuer가 신뢰 대상이거나, 이 토큰을 현재 API가 받아도 된다는 뜻은 아닙니다.
빠르게 내용을 확인하려면 JWT Decoder를 사용합니다. 코드 안에서 같은 작업을 재현해야 할 때는 아래 순서를 따릅니다.
제가 JavaScript에서 JWT를 디코딩하는 순서
저는 보통 다음 여섯 단계를 고정합니다.
Bearer또는Authorization: Bearer접두어를 제거합니다.- 예상한 compact JWT 형식인지 segment 수를 확인합니다.
- header와 payload를 Base64URL에서 bytes로 변환합니다.
- bytes를 UTF-8로 디코딩합니다.
- JSON 파싱 오류를 별도로 처리합니다.
exp,nbf,iat,iss,aud를 읽되 서명 검증과 혼동하지 않습니다.
이 순서를 지키면 짧은 one-liner가 숨기기 쉬운 인코딩 문제와 입력 형식 문제를 초기에 분리할 수 있습니다.
1. 입력을 먼저 정규화합니다
실제 디버깅에서는 순수 JWT만 복사되는 경우보다 다음처럼 header 전체가 복사되는 경우가 많습니다.
Bearer eyJ...
또는:
Authorization: Bearer eyJ...
그래서 저는 먼저 입력을 한 가지 형태로 정규화합니다.
function normalizeJwtInput(input) {
return input
.trim()
.replace(/^authorization\s*:\s*bearer\s+/i, "")
.replace(/^bearer\s+/i, "")
.trim()
.replace(/^['"]|['"]$/g, "");
}
정규화 단계에서 다른 의미를 추측하지는 않습니다. 이후 로직이 compact token 하나만 받도록 입력 계약을 단순하게 만드는 것이 목적입니다.
2. segment 수를 먼저 확인합니다
일반적인 서명형 compact JWT/JWS는 다음처럼 세 부분입니다.
header.payload.signature
반면 compact JWE는 보통 다섯 segment를 사용합니다. 저는 디코딩 전에 이 차이를 확인합니다.
function splitJwt(input) {
const token = normalizeJwtInput(input);
const parts = token.split(".");
if (parts.length === 5) {
throw new Error("3-part signed JWT가 아니라 encrypted JWE로 보입니다.");
}
if (parts.length !== 3) {
throw new Error(`JWT segment가 3개여야 하는데 ${parts.length}개입니다.`);
}
return parts;
}
token.split('.')[1]만 바로 읽는 코드는 malformed input에서도 원인을 잘 설명하지 못합니다. 저는 형식 오류와 데이터 오류를 분리해서 보고 싶기 때문에 먼저 segment를 검증합니다.
3. Base64URL을 일반 Base64처럼 취급하지 않습니다
JWT segment는 RFC 4648의 URL-safe alphabet을 사용합니다. +와 / 대신 -와 _를 사용하고 padding =가 생략되는 경우가 흔합니다.
브라우저에서는 먼저 alphabet과 padding을 정리합니다.
function base64UrlToBytes(segment) {
const base64 = segment
.replace(/-/g, "+")
.replace(/_/g, "/");
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"="
);
const binary = atob(padded);
return Uint8Array.from(binary, char => char.charCodeAt(0));
}
atob()의 결과는 문자 인코딩이 완료된 텍스트가 아니라 byte 값을 표현하는 binary string에 가깝습니다. 여기서 바로 JSON.parse()로 넘어가면 UTF-8이 아닌 단순 ASCII 데이터에서는 우연히 잘 동작하다가 비ASCII claim에서 깨질 수 있습니다.
4. UTF-8을 명시적으로 처리합니다
JWT JSON은 UTF-8입니다. 이름, 조직명, 다국어 custom claim처럼 ASCII 범위를 벗어나는 데이터가 들어갈 수 있기 때문에 저는 TextDecoder를 사용합니다.
function decodeJsonSegment(segment, label) {
let text;
try {
const bytes = base64UrlToBytes(segment);
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new Error(`${label}: 유효한 Base64URL/UTF-8 데이터가 아닙니다.`);
}
let value;
try {
value = JSON.parse(text);
} catch {
throw new Error(`${label}: 유효한 JSON이 아닙니다.`);
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label}: JSON object여야 합니다.`);
}
return value;
}
Base64URL 오류, UTF-8 오류, JSON 오류를 한 메시지로 뭉치지 않으면 실제 장애를 훨씬 빨리 좁힐 수 있습니다.
5. 작은 decoder를 조립합니다
function decodeJwt(input) {
const [headerSegment, payloadSegment, signature] = splitJwt(input);
return {
header: decodeJsonSegment(headerSegment, "JWT header"),
payload: decodeJsonSegment(payloadSegment, "JWT payload"),
signature,
};
}
여기까지 성공했다면 얻은 것은 decoded token입니다. verified token이 아닙니다.
저는 보통 다음 값을 먼저 봅니다.
const decoded = decodeJwt(token);
console.log(decoded.header.alg);
console.log(decoded.header.kid);
console.log(decoded.payload.iss);
console.log(decoded.payload.aud);
console.log(decoded.payload.exp);
6. exp는 초 단위라는 점을 놓치지 않습니다
JWT의 NumericDate인 exp, iat, nbf는 Unix epoch 기준 초입니다. JavaScript의 Date.now()는 밀리초이므로 그대로 비교하면 단위가 맞지 않습니다.
function expirationState(payload) {
if (typeof payload.exp !== "number") {
return { hasExpiration: false };
}
return {
hasExpiration: true,
expiresAt: new Date(payload.exp * 1000),
expired: Date.now() >= payload.exp * 1000,
};
}
제가 피하는 코드는 이것입니다.
Date.now() >= payload.exp // 단위가 다름
시간 claim 자체를 점검하는 것이 목적이라면 JWT Expiry Checker에서 iat, nbf, exp와 활성 구간을 한 번에 확인하는 편이 빠릅니다.
Node.js에서는 어떻게 하는가
Node.js에서는 Buffer가 Base64URL을 직접 처리할 수 있어 더 간단하게 쓸 수 있습니다.
function decodeBase64UrlJson(segment) {
return JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
}
function decodeJwtNode(token) {
const parts = token.trim().split(".");
if (parts.length !== 3) throw new Error("3-part signed JWT가 필요합니다.");
return {
header: decodeBase64UrlJson(parts[0]),
payload: decodeBase64UrlJson(parts[1]),
signature: parts[2],
};
}
서버가 실제 인증 결정을 내려야 한다면 저는 이 decoder만 사용하지 않습니다. 검증 라이브러리로 서명을 확인하고, 애플리케이션이 알고 있는 issuer·audience·algorithm 정책을 별도로 강제합니다.
jwt-decode를 사용할 때의 경계
직접 Base64URL 코드를 유지하고 싶지 않을 때는 jwt-decode 같은 라이브러리를 사용할 수 있습니다.
import { jwtDecode } from "jwt-decode";
const payload = jwtDecode(token);
const header = jwtDecode(token, { header: true });
하지만 라이브러리 이름 때문에 decode를 verify로 착각해서는 안 됩니다. 라이브러리 문서도 디코딩과 검증을 구분합니다. 저는 client-side inspection에는 decoder를 사용하지만 서버의 인증 gate에는 검증 로직을 사용합니다.
왜 JSON.parse(atob(token.split('.')[1]))를 피하는가
이 한 줄은 테스트 토큰에서 잘 동작하는 것처럼 보일 수 있지만 다음 문제를 숨깁니다.
- segment가 실제로 존재하는지 확인하지 않습니다.
- Base64URL과 Base64 차이를 명시적으로 처리하지 않습니다.
- padding을 고려하지 않습니다.
- UTF-8 claim에서 깨질 수 있습니다.
- header의
alg,kid를 놓칩니다. - malformed input에 대한 오류가 불친절합니다.
- 가장 위험하게는 “읽혔다 = 검증됐다”는 잘못된 인상을 만들기 쉽습니다.
디코딩 다음에는 무엇을 검증하는가
제가 trust 판단이 필요할 때는 JWT Signature Verifier 또는 JWKS Validator로 이동합니다. header의 alg와 kid는 어떤 키와 알고리즘을 확인해야 할지 이해하는 단서일 뿐, 토큰이 자기 자신의 trust policy를 결정하게 만들면 안 됩니다.
서명 검증 이후에도 다음 claim은 별도 정책입니다.
exp: 만료됐는가nbf: 아직 활성화 전인가iss: 신뢰하는 issuer와 정확히 일치하는가aud: 이 API를 대상으로 발급됐는가- scope/role: 현재 작업에 필요한 권한이 있는가
이 구분이 필요하면 JWT Validator와 JWT Debugger를 함께 사용합니다.
브라우저에서 JWT를 다룰 때 제가 지키는 원칙
JWT payload가 읽힌다고 해서 token이 민감하지 않은 것은 아닙니다. access token은 여전히 credential입니다.
그래서 저는:
- JWT를 URL query/hash에 넣지 않습니다.
- production bearer token 전체를 로그에 남기지 않습니다.
- 단순 디버깅을 위해 localStorage/sessionStorage에 저장하지 않습니다.
- 가능하면 synthetic 또는 redacted token으로 재현합니다.
- 단순 디코딩을 위해 토큰을 서버 API로 전송하지 않습니다.
JWTDecoder.tools의 decoder도 같은 원칙으로 브라우저 메모리 안에서 처리합니다.
제 JavaScript JWT 디코딩 체크리스트
- Bearer 접두어를 제거합니다.
- 3-part signed JWT인지 확인합니다.
- Base64URL을 bytes로 변환합니다.
- bytes를 UTF-8로 읽습니다.
- header/payload JSON을 오류 처리와 함께 파싱합니다.
alg,kid,iss,aud, 시간 claim을 확인합니다.- NumericDate를 JavaScript 날짜로 바꿀 때
* 1000합니다. - 결과를
decoded라고 부르고verified라고 부르지 않습니다. - trust가 필요하면 신뢰하는 key/JWKS로 서명을 검증합니다.
- acceptance가 필요하면 issuer, audience, expiration, token type, 권한을 별도로 검사합니다.
JavaScript로 JWT를 읽는 코드 자체는 어렵지 않습니다. 중요한 것은 읽을 수 있음, 암호학적으로 검증됨, 내 애플리케이션 정책에 허용됨을 서로 다른 상태로 유지하는 것입니다.