What's Actually Inside a JWT (And Why You Shouldn't Trust the Payload Blindly)
By Sarfaraz Khan ·
A JSON Web Token looks like a secret. It's a long, opaque-looking string, it shows up in an
Authorization header, and it controls who gets access to what. So it's a very common — and very
wrong — assumption that a JWT is encrypted. It isn't. Let's actually pull one apart.
Three parts, one job each
A JWT is three base64url segments joined by dots: header.payload.signature. Each part is doing a
different job, and none of them involve encryption by default:
- Header — a tiny JSON object naming the signing algorithm, e.g.
{"alg":"HS256","typ":"JWT"}. - Payload — the actual claims:
sub(who this token is about),exp(when it expires),iat(when it was issued), and whatever else the issuer put in there. This is just base64url-encoded JSON. Not encrypted. Not obfuscated in any meaningful way. Anyone holding the token — including the end user it was issued to — can decode this segment and read every field in about two seconds. - Signature — a keyed hash (HMAC) or a public-key signature (RSA/ECDSA) over the first two parts. This is the only part that proves the token wasn't tampered with after it was issued.
If you want to see this for yourself on a real token rather than take my word for it, paste one into a JWT Decoder & Verifier — it splits out all three parts, shows you the decoded header and payload as readable JSON, and tells you whether the signature actually checks out against a secret or public key you provide.
The mistake this leads to
Because the payload is so easy to read, it's tempting to just... read it, and trust what it says,
without checking the signature at all. A frontend that decodes a JWT client-side to check
payload.role === "admin" and shows an admin UI based on that check has built precisely nothing
security-relevant — a user can edit that payload locally and see whatever the JavaScript, gated only
by that check, would show. The server still has to verify the signature on every request that
matters; client-side decoding is fine for display purposes (showing a username, say) and never fine
as the actual access-control decision.
The same logic applies to expiry. exp being in the past doesn't make the token cryptographically
invalid — it's still a validly signed token, just one your server should choose to reject. "Signature
invalid" and "token expired" are two completely separate checks, and conflating them is a common
source of confusing auth bugs: a token can have a perfectly valid signature and still be correctly
rejected for being expired, or (much worse) an expired-looking payload can be paired with a signature
that doesn't validate at all, meaning someone tampered with the claims.
Where this shows up in the wild
When a JWT-protected endpoint rejects a request, the reason matters for how you fix it, and this is where a lot of people reach straight for "clear localStorage and log in again" without knowing why that's the fix — or whether it even will be. A 401 generally means the server doesn't like the token itself (missing, expired, bad signature); a 403 means the token is fine but the identity it represents isn't allowed to do this specific thing. If you're not sure which one you're looking at or what the distinction implies for your fix, an HTTP Status Code & Header Reference spells out exactly what each code is supposed to mean, so you're not guessing.
One more practical note: if you ever need to log a JWT for debugging, or paste one into a config file or a support ticket, remember it's not a secret by itself in the sense of being encrypted — but it is a live credential, and logging it whole is effectively logging a working session token. If you need to safely embed a token (or any string with special characters) inside a JSON config or a log line without breaking the surrounding syntax, a JSON to String Converter handles the escaping correctly rather than you hand-editing quotes and backslashes.