Half the "works in Postman, fails in production" bugs I've debugged were readable in the token itself. Expired exp. Audience minted for a different client. A token from the wrong environment variable. The payload was sitting right there, base64url-encoded, one paste away — and instead of reading it, we were reading stack traces.
So here is the habit I've settled on: decode first, theorize second. It turns most auth mysteries into five-minute fixes.
The 30-second version
A JWT is three base64url segments joined by two dots: header, payload, signature. The first two are not encrypted — they are encoded, which means they are readable by anyone who holds the token. That is by design. The signature is the only part that proves authenticity; the payload merely claims.
The fastest decode is a browser tool, because it keeps the token on your machine (a debug token with a live access_token in it has no business being pasted into a random web form). I built a JWT decoder that runs entirely client-side: paste, read the header and payload as formatted JSON, see the expiry status at a glance. Thirty seconds, zero network.
Once you can read tokens fluently, five claims decide most real-world outcomes:
| Claim | Meaning | What catches people |
|---|---|---|
exp |
Expiry, Unix seconds | The token was dead the whole time; clocks and "it worked yesterday" lie |
iss |
Who issued it | Expected Auth0, got https://accounts.google.com — wrong env var |
aud |
Who it's for | Minted for a different client_id than the one sending it |
scope |
What it may do | The integration promised read:orders, the token says otherwise |
sub |
Who it is | A user id where you expected a service account, or vice versa |
If you want the full walkthrough — the field-by-field reasoning, the failure modes, the "only part of the token got copied" classics — I wrote a longer guide on how to decode a JWT token, and it is the reference I'll paraphrase below.
Access token vs ID token: same dots, different jobs
Here's the triage that took me embarrassingly long to internalize. People paste two very different tokens into a decoder and expect similar answers.
An access token is the thing you send as Authorization: Bearer .... It may be a JWT or an opaque string. If it decodes into three parts, its exp, scope and aud tell you what it can do and until when.
An ID token comes from an OIDC login flow. It is always a JWT, but it describes the user — email, name, sub — not permissions.
Three-second triage table:
| Payload contains | You're holding | Check |
|---|---|---|
exp, scope, short sub
|
access token | Is exp past? Is scope what the integration promised? |
email, name, aud = your client_id |
ID token | Does aud match your client? The nonce you sent? |
| No dots, random string | opaque token | Nothing to decode — resolve it at the issuer's introspection endpoint |
The classic bug this catches: sending the ID token to your API because it was the token lying around after login, then wondering why the API rejects it. The API wanted the access token. The payload told you which one you had the entire time — that access token vs ID token comparison is worth bookmarking the next time an integration "randomly" 401s.
What the libraries actually do (four languages, one pattern)
Every serious JWT library separates decoding from verification. Knowing the exact shape of that separation in your language saves an afternoon:
Node (jsonwebtoken) — jwt.decode(token) decodes without verifying: the direct equivalent of pasting into a browser tool. jwt.verify(token, secretOrPublicKey) checks the signature and throws on failure, with the reason named in the error (TokenExpiredError, JsonWebTokenError). Decode to inspect, verify to trust.
Python (PyJWT) — inspection is jwt.decode(token, options={"verify_signature": False}). Verification demands the algorithm explicitly: algorithms=["RS256"]. That friction is deliberate — it exists to prevent the alg: none downgrade class of bugs, where a crafted token declares no signature and a lazy verifier obligingly skips the check.
Go (golang-jwt) — parsing and validation are separate calls. The interesting part for multi-tenant systems: key lookup happens by the token's kid header, so the practical pattern is decode-first (read kid, route to the right tenant's key), then validate.
Java (jjwt) — parseClaimsJwt() handles unsigned parsing; anything signature-checked wants the key at parse time.
Four ecosystems, one philosophy: verification is the loud, default path; decoding is the quiet, explicit one. When you find yourself fighting the library to "just decode the thing," that's not friction to work around — it's the library telling you which operation deserves suspicion.
Decoding is not verifying
The one sentence that belongs in every code review: a decoded payload is a claim, not a fact. Anyone can mint a token that says {"role":"admin"}. The signature is the only part that proves who issued it and that nothing changed in transit.
And the red flag to say out loud: if a token's header reads alg: none and the signature segment is empty, it is asking to be trusted with zero proof. Production systems reject it. Debuggers treat it as a finding, not a curiosity.
The habit, compressed
Decode before you theorize. Check exp, iss, aud, scope in that order. Know whether you're holding an access token or an ID token before you blame the API. Trust signatures, not payloads. And when a token misbehaves, paste it somewhere safe before pasting it into a search engine — the answer is usually in the middle segment.
I build Ajiez, a collection of free, local-only browser tools — the JWT decoder above is one of them. Everything runs client-side: your tokens, keys and files never leave your machine.
Top comments (0)