When I need to decode a JWT in JavaScript, I keep three states separate from the first line of code: decoded, cryptographically verified, and acceptable to my application. A decoder only reaches the first state. That distinction matters because almost every dangerous JWT shortcut starts by treating readable claims as trusted claims.
If I only need an inspection, I use the JWT Decoder. When I need the behavior in browser or Node code, I use the same explicit pipeline below.
The six stages I expect in a reliable decoder
- Normalize copied input such as
Bearer .... - Check the compact token shape before indexing segments.
- Convert Base64URL to bytes correctly.
- Decode bytes as UTF-8.
- Parse JSON with errors that identify the failing stage.
- Inspect claims while keeping signature verification separate.
A one-liner can appear to work for ASCII happy paths while silently skipping several of these boundaries.
Step 1: normalize real-world input
Developers often paste a full header value rather than the raw token:
Authorization: Bearer eyJ...
I normalize it first:
function normalizeJwtInput(input) {
return input
.trim()
.replace(/^authorization\s*:\s*bearer\s+/i, "")
.replace(/^bearer\s+/i, "")
.trim()
.replace(/^['"]|['"]$/g, "");
}
This prevents a common class of jwt malformed, “expected 3 segments”, or Base64 errors caused by copying transport syntax into a parser.
Step 2: distinguish JWS-shaped JWTs from JWE and malformed input
A common compact signed JWT/JWS has three segments:
header.payload.signature
A compact JWE normally has five. I fail early:
function splitSignedJwt(input) {
const token = normalizeJwtInput(input);
const parts = token.split(".");
if (parts.length === 5) {
throw new Error("Encrypted JWE detected; this decoder expects a 3-part signed JWT/JWS.");
}
if (parts.length !== 3) {
throw new Error(`Expected 3 JWT segments, received ${parts.length}.`);
}
return { token, header: parts[0], payload: parts[1], signature: parts[2] };
}
I prefer this to token.split('.')[1] because malformed input gets a useful diagnosis instead of a secondary exception.
Step 3: Base64URL is not plain Base64
JWT segments use the URL-safe alphabet from RFC 4648. - and _ replace + and /, and padding is commonly omitted.
For a browser implementation:
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));
}
If invalid characters reach atob(), browsers may throw an InvalidCharacterError. I catch that separately from JSON parsing so I know whether the problem is encoding or content.
Step 4: decode bytes as UTF-8, not as “whatever atob returned”
atob() gives a binary string representation of bytes. It does not make arbitrary UTF-8 text safe by itself. For names and localized custom claims, I use TextDecoder:
function decodeJsonSegment(segment, label) {
let bytes;
try {
bytes = base64UrlToBytes(segment);
} catch {
throw new Error(`${label} is not valid Base64URL.`);
}
let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new Error(`${label} is not valid UTF-8.`);
}
try {
const value = JSON.parse(text);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error();
}
return value;
} catch {
throw new Error(`${label} is not a JSON object.`);
}
}
Separating Base64URL, UTF-8, and JSON failures makes production debugging much faster.
A dependency-free browser decoder I would actually keep
function decodeJwt(input) {
const { token, header, payload, signature } = splitSignedJwt(input);
return {
token,
header: decodeJsonSegment(header, "JWT header"),
payload: decodeJsonSegment(payload, "JWT payload"),
signature
};
}
Usage:
try {
const decoded = decodeJwt(rawInput);
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);
} catch (error) {
console.error(error.message);
}
The result is decoded data, not a trust decision.
Node.js: use Buffer when it makes the code simpler
Modern Node supports the base64url encoding directly:
function decodeBase64UrlJson(segment) {
const text = Buffer.from(segment, "base64url").toString("utf8");
return JSON.parse(text);
}
function decodeJwtNode(token) {
const parts = normalizeJwtInput(token).split(".");
if (parts.length !== 3) throw new Error("Expected a 3-part signed JWT");
return {
header: decodeBase64UrlJson(parts[0]),
payload: decodeBase64UrlJson(parts[1]),
signature: parts[2]
};
}
This is for inspection. Server authentication should use a JOSE/JWT library that verifies signature and enforces issuer, audience, algorithm, and time policies.
jwt-decode: useful decoder, deliberately not a verifier
For client-side inspection I may use Auth0's jwt-decode:
import { jwtDecode } from "jwt-decode";
const payload = jwtDecode(token);
const header = jwtDecode(token, { header: true });
Its contract is intentionally decoding, not signature validation. I therefore do not use a successful jwtDecode() call as an authentication gate.
Malformed input can surface as InvalidTokenError; I handle that as input failure, not “invalid signature”. Those are different phases.
Why I avoid JSON.parse(atob(token.split('.')[1]))
That shortcut:
JSON.parse(atob(token.split(".")[1]));
has several hidden assumptions:
- payload segment exists;
- Base64URL happens to be accepted as Base64;
- padding is okay;
- text is ASCII-compatible;
- payload is valid JSON;
- no distinction between JWS and JWE is needed;
- the reader remembers decoding did not verify anything.
For disposable debugging it may work. For reusable code it hides exactly the edge cases that later become hard-to-explain bugs.
NumericDate: exp is seconds, JavaScript dates are milliseconds
function inspectExpiration(payload) {
if (typeof payload.exp !== "number") return { hasExp: false };
const expiresAtMs = payload.exp * 1000;
return {
hasExp: true,
expiresAt: new Date(expiresAtMs),
expired: Date.now() >= expiresAtMs
};
}
I never write:
Date.now() >= payload.exp // wrong units
For a complete time view I use the JWT Expiry Checker.
Browser, Node, SSR, and edge runtimes do not have identical globals
A common implementation bug is assuming code written for one JavaScript runtime works everywhere.
| Runtime | Useful primitive | Caveat |
|---|---|---|
| Browser | atob, TextDecoder | atob is byte-oriented |
| Node | Buffer.from(..., "base64url") | window/atob assumptions may fail |
| Edge runtimes | Web APIs often available | runtime-specific Node APIs may not exist |
| SSR React/Next | server + browser contexts | never assume browser globals during server render |
If I ship shared code, I choose APIs supported by the intended runtime rather than probing until the bundle compiles.
TypeScript: I do not pretend decoded claims have a trusted shape
A type assertion does not validate data:
interface Claims {
sub?: string;
exp?: number;
aud?: string | string[];
}
const claims = decodeJwt(token).payload as Claims; // only a compile-time assertion
For untrusted input, I still perform runtime checks:
function hasNumericExp(value: unknown): value is { exp: number } {
return !!value && typeof value === "object" &&
typeof (value as Record<string, unknown>).exp === "number";
}
TypeScript cannot turn attacker-controlled JSON into trusted claims.
Unicode is the test case I use to catch fragile decoders
I include a synthetic payload with non-ASCII text in tests:
{
"name": "Zoë 李",
"city": "München",
"scope": "read:プロフィール"
}
If a custom decoder only worked because all previous tokens were ASCII, this test exposes it.
Decoding, verifying, and accepting are different decisions
My mental model is:
Can I parse it?
↓ decode
Did a trusted key sign these exact bytes?
↓ signature verification
Does this application accept issuer/audience/time/type/policy?
↓ claim + application validation
For the second step I use JWT Signature Verifier or JWKS Validator. For the third, JWT Validator and JWT Debugger.
Security rules I keep around client-side decoding
I treat access tokens as credentials even though the payload is readable:
- never put production JWTs in URLs;
- avoid logging complete bearer tokens;
- prefer synthetic/redacted reproduction tokens;
- do not persist debugging tokens unless the application security model requires it;
- do not send a token to a server merely to Base64URL-decode it;
- never authorize a user solely from client-side decoded claims.
The decoder on JWTDecoder.tools follows that model: decoding happens in browser memory.
My JavaScript decoder test matrix
I test at least these inputs:
valid 3-part ASCII token -> decode
valid token with Unicode claim -> decode
Bearer-prefixed token -> normalize + decode
Authorization: Bearer ... -> normalize + decode
invalid Base64URL -> encoding error
valid Base64URL but invalid UTF-8 -> UTF-8 error
valid text but invalid JSON -> JSON error
2-part malformed input -> shape error
5-part compact JWE -> report JWE, do not pretend JWS
This is more valuable than testing one happy-path token repeatedly.
My final decision flow
- Normalize the input.
- Check segment count and distinguish JWS/JWE.
- Base64URL-decode header and payload to bytes.
- Decode bytes as UTF-8.
- Parse each segment as a JSON object.
- Inspect
alg,kid,iss,aud,exp,nbf, andiatas untrusted data. - Convert NumericDate values using seconds, not milliseconds.
- Label the result decoded, never verified.
- If authenticity matters, verify the original compact token with trusted key material.
- If acceptance matters, validate issuer, audience, time, token type, and authorization policy.
The difficult part of JWT decoding is not JSON.parse. It is preserving the boundary between readable data and trusted identity data across every JavaScript runtime where the code can run.