A JWT looks like encrypted gibberish: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIx.... Here is the uncomfortable part — it is not encrypted. Anyone holding the token can read everything in it. That is by design, and it is also why a surprising number of "why is auth broken?" bugs get solved in about ten seconds once you actually look inside the token.
Three segments, no secrets
A JWT is header.payload.signature, each part Base64URL-encoded, joined by dots. Base64URL is just an encoding (URL-safe alphabet, padding stripped so it can live in a header, cookie, or query string). The first two segments are plain JSON. The third is a signature over the first two — a signature proves nobody tampered with the token, it does not hide anything.
Decode one in Node, no dependencies:
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123';
const [, payload] = token.split('.');
const claims = JSON.parse(Buffer.from(payload, 'base64url'));
console.log(claims);
Python:
import base64, json
payload = token.split('.')[1]
claims = json.loads(base64.urlsafe_b64decode(payload + '==='))
What to check when auth fails
Almost every JWT bug is one of these claims, and they are all Unix seconds:
-
exp— expiry. Expired tokens produce a 401 that has nothing to do with your code. -
nbf— "not before". A token used too early is invalid, which bites when servers' clocks drift. -
aud/iss— audience and issuer. One service rejecting a token issued for a different audience is extremely common in multi-service setups.
Convert exp and compare against the clock before you start rewriting middleware.
The bug that is not a bug: alg: none
The header carries alg, and the single worst JWT mistake is letting the client tell the server which algorithm to trust. If a server honours "alg": "none", an attacker deletes the signature and forges any payload they want. A subtler variant is algorithm confusion: an RS256 token re-signed as HS256 using the public key as the HMAC secret. Fix both by pinning the accepted algorithms server-side and always verifying with a real library (jose, PyJWT, jsonwebtoken) — never by decoding and trusting.
Don't put anything private in the payload
Roles, internal IDs, emails, feature flags — all readable by whoever holds the token, including anyone who finds it in a log or a URL. Keep the payload minimal; look up the rest server-side.
When I'm debugging one of these, I decode the token first. I've been using CodeToolbox's JWT decoder — it runs entirely in the browser (no token leaves your machine), prints the header and claims, and converts exp/iat/nbf into readable local time so I can see instantly whether the token is expired. It does not verify signatures, which is the honest limitation of any browser-side tool: verification needs the key and belongs on your server.
Token debugging tip of the day: read the claims before you touch the code. Most of the time, the token was simply expired.
Top comments (0)