How to decode a JWT — and what to actually look for
You've seen this movie. The API returns 401. The frontend swears it's sending the token. The token "looks fine." Twenty minutes later you're squinting at base64 on a whiteboard, wondering whether the bug is yours, the auth service's, or the universe's.
Here's the thing: a JWT will tell you exactly what's wrong with it. It's just that almost nobody reads it. Decoding takes ten seconds — knowing which of the dozen claims in front of you is the liar is the actual skill. That's what this post is about.
- How a JWT is actually put together — three segments, zero mystery
- Decode one by hand and with the decoder
- The claims that bite:
exp,nbf,iat,iss,aud - Why "decoded" is not "verified" — and the
alg: nonetrap - A 60-second checklist to run before blaming anyone
What a JWT actually is
A JWT is three base64url strings joined by dots: header.payload.signature.
That's the whole format. Here's the classic sample token from jwt.io's debugger:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Those first two segments aren't mystery strings — they're JSON. Base64url is just an encoding, the same way "saying it out loud" is an encoding. Which leads to rule one:
Anyone holding the token can read every claim in it. Never put passwords, session secrets, or anything sensitive in a payload. A JWT is a signed label, not a safe.
The header names the signing algorithm (alg) and the token
type. The payload carries the claims — who you are, when the token
dies, who minted it. The signature is the only part that isn't JSON:
it's the HMAC (or RSA/ECDSA) output over the first two segments, and it's the
only reason any of this can be trusted.
Small fact that pays off in code review: nearly every JWT starts with
eyJ. That's not a prefix or a version marker — it's just what
{"alg":… becomes in base64. Spot it in a log line and you can
call it out before anyone pastes anything.
Decode one in ten seconds
Two ways. The fast way: paste it into
the JWT decoder. It strips the
Bearer prefix if you copied the whole Authorization header, and
renders the header and payload as readable JSON the instant you paste.
The no-tools way, because someday you'll be SSH'd into a box with no tabs open:
// split, decode, read — that's the whole trick
const [header, payload, sig] = token.split('.');
const claims = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
);
console.log(claims);
The two replace() calls convert base64url's - and
_ back to base64's + and / — that's the
entire difference between the alphabets. The signature won't decode to
readable text; it's raw bytes, and that's normal. Don't fight it.
The sample token above decodes to this:
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
That iat is January 18, 2018 — and note the unit. JWT timestamps
are always seconds, never milliseconds. JavaScript's
Date.now() returns milliseconds, and the mismatch between the two
is an entire genre of "why has my token been expired since 1970" bugs.
exp — the alert does the reading for you.The claims that actually bite you
A payload can carry anything, but the registered claims are the ones that cause incidents. In order of how often they page people:
exp — the usual suspect
Expiry, in seconds since the epoch. If your 401 started "suddenly," this is it nine times out of ten. Compare it against the server's clock — not yours, not the browser's — and remember the seconds-vs-milliseconds trap above.
nbf — the sneaky one
"Not before." The token is perfectly valid — just not yet. You see this with freshly minted tokens and clock skew between the machine that signed and the machine that verifies. A token rejected at 09:00:00 that works at 09:00:04 is a clock problem, not an auth problem.
iat — the detective
"Issued at" rarely breaks anything itself, but it's how you catch stale tokens. Issued before the password reset? Before the user's permissions changed? Three days ago on a fifteen-minute access token? Something upstream is caching what it shouldn't be.
iss and aud — right token, wrong door
Issuer and audience. In any system with more than one API, this is the classic
failure: the token is cryptographically valid and signed by someone you trust —
it just wasn't meant for this service. Check for exact string matches,
including https:// and trailing slashes.
https://auth.example.com and https://auth.example.com/
are two different audiences as far as a strict verifier cares.
sub — who is this, actually
The subject. Usually a user ID, sometimes a service account, occasionally something weirder. Don't assume the shape — read what's actually in there before you build on top of it.
Here's what a more realistic payload looks like once a few of these show up:
{
"iss": "https://auth.example.com", // who minted this
"sub": "usr_8f2k1", // who it's about
"aud": "https://api.example.com", // who it's FOR
"role": "admin",
"iat": 1785456000, // issued Jul 31, 00:00 UTC
"nbf": 1785456000, // valid from the same instant
"exp": 1785459600 // dead after 01:00 UTC
}
If exp says the token expired "three seconds ago," the token
is fine and two machines disagree about what time it is. Sync NTP on both
sides, or add a few seconds of leeway in the verifier. Blaming the token
here changes nothing.
Decoded ≠ verified — the alg:none trap
This is the one that matters for security, so let's be blunt.
Decoding a JWT proves nothing. It's reading the label on a bottle.
Anyone can base64-encode any JSON they like — including
{"role": "admin"} — and hand it to your API. The only thing
standing between a readable token and a trustworthy one is the signature.
The classic exploit is alg: none. The header is attacker-controlled
input that names the algorithm — so an attacker sets it to
none, deletes the signature entirely, and sends
header.payload. with an empty third segment:
// header: attacker-chosen · signature: deleted
{ "alg": "none", "typ": "JWT" }
eyJhbGciOiJub25lIn0.eyJyb2xlIjoiYWRtaW4ifQ.
↑ note the empty third segment
A verifier that reads alg and follows instructions will shrug and
accept it. Serious libraries refuse outright — but this exact bug has shipped
in production frameworks more times than anyone likes to count.
Never make an authorization decision from a token you only decoded. Decoding is for you; verification is for the code.
One paragraph on algorithms: HS256 signs with a shared secret (HMAC-SHA256) — simple and fast, but anyone who can verify can also forge, so the secret stays server-side. RS256 signs with a private key and verifies with a public one — what you want when the verifier is a different service than the issuer. If the "SHA-256" part of that sentence raised questions, there's a whole post on when each hash is actually fine.
And yes — the decoder does real HS256 verification with Web Crypto, timing-safe comparison included. The secret you type in never leaves your machine, which is rather the point.
When the token is fine and everything else is wrong
Maybe half the 401s I've debugged involved a completely innocent token. Before you blame the auth service, walk the delivery path:
- Is it being sent at all? Check the actual request in the Network tab — the Authorization header, the full value, on the request that failed.
- Is it formatted right?
Bearer, capital B, one space, no quotes, no trailing newline. Some frameworks choke on any of those. - Right environment? A staging token against the prod API — or a token signed with the secret that got rotated yesterday — is valid JSON and a guaranteed 401.
- Did it expire in transit? Short-lived access tokens plus a slow upload means the token can die between the click and the request. Nothing is broken; the TTL is just honest.
- Is it even auth? A failed CORS preflight can surface as an auth-shaped error in some frameworks. Read the actual response body, not the toast.
The pattern: verify the token arrived, intact, at the thing that rejected it. Most "token problems" are luggage problems.
The 60-second checklist
Tape this to the monitor:
- Decode it.
expin the past? It's expired — stop here. nbfin the future? Not valid yet. Check both clocks.issandaudmatch your API exactly? String-exact, scheme and trailing slash included.- Signature verified against the right key? Decoded ≠ verified.
- Clocks agree within a few seconds? NTP is a lifestyle.
- Token actually in the header?
Bearerintact, no stray whitespace.
Nine times out of ten, the answer is in the first three steps. The decoder does step one, step four (for HS256), and half of step six for you — paste, read the alerts, get on with your night.
Wrap-up
A JWT is the rare debugging situation where the evidence is fully in your hands — no logs to request, no access to beg for. Decode it, read the claims in order of how often they lie, and verify before you trust anything in it.
Next up on the blog: cron expressions in ten minutes — including the day-of-month / day-of-week OR rule that pages people at 5am. And if a token ever survives all six checklist steps and still 401s? That's what the contact page is for. Misery loves company.