Debugging JWTs Client-Side: Header, Claims, Expiry and Privacy
Somebody on your team decodes a JWT most days. An endpoint returns 401, the token looks fine, and the fastest way to find out what the API actually objected to is to read the claims. It takes ten seconds.
Those ten seconds are also how a live production credential ends up in a stranger’s request log.
What a JWT actually is
A JSON Web Token in its usual compact form is three base64url-encoded segments separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- The header says how the token was signed —
{"alg":"HS256","typ":"JWT"}. - The payload carries the claims: who the token is about, who issued it, when it expires.
- The signature is a MAC over the first two segments, computed with a key only the issuer holds.
The critical property, and the one that surprises people: the payload is encoded, not encrypted. Base64url is a transport encoding with no secret in it. Anyone holding the token can read every claim. The signature stops the token being modified; it does nothing to stop it being read.
This is why the standard advice is never to put anything sensitive in a JWT payload. It is also why a decoder needs no key to show you the contents — and why “decoding” is not a privileged operation at all.
Why pasting a live token matters
A bearer token is a credential in the most literal sense: the API grants access to whoever bears it. It is not a password that identifies you and then gets checked — it is the access. Anyone who obtains an unexpired token can make requests as that subject, with those scopes, until it expires.
So when you paste a live token into a decoder that posts it to a server, you have transmitted a working credential to a third party. Not a hash of one, not a reference to one. The token itself. Whether anything bad comes of it depends entirely on that server’s logging policy, which you cannot see.
Three habits make this a non-issue:
- Decode locally. A tool that runs in the browser makes no request, so there is nothing to log. The JWT Decoder does the decoding in JavaScript on the page.
- Prefer an expired or test token. For debugging a claim structure, an expired token tells you everything a live one would.
- Rotate what you have already pasted. If a live token went into a tool you do not control, treat it as disclosed.
This is the same reasoning that applies to any browser-based developer tool — the stakes are just unusually concrete with auth tokens.
The claims that explain most failures
Registered claims are defined in RFC 7519 and are three letters each, which makes them easy to misremember.
exp — expiration. A Unix timestamp in seconds. The token must be rejected
on or after this moment, so a token whose exp equals the current second is
already invalid. This is the single most common cause of a sudden 401 on a
request that worked a minute ago.
iat — issued at. Useful mostly as a sanity check. If iat is in the
future, you have a clock-skew problem between the issuer and the verifier, and
skew is a genuinely common cause of intermittent auth failures in distributed
systems.
nbf — not before. The mirror of exp. A token can be valid but not yet
valid.
All three are Unix timestamps, which are unreadable at a glance — 1718409600
tells you nothing about whether it is in the past. Converting them is what the
Timestamp Converter is for, and a good decoder
does it for you.
iss — issuer and aud — audience. When a token is not expired and
still gets rejected, these two are usually why. A token minted by your staging
issuer will not be accepted by production, and a token whose aud names a
different API will be rejected by a correctly configured verifier even though
everything about it looks valid. Comparing iss and aud against what the
service expects resolves a surprising share of “but the token is fine” tickets.
scope or scp — permissions. A 403 rather than a 401 usually means the
token authenticated fine and simply lacks the scope for that operation.
One header field worth checking
If the header says "alg": "none", the token is unsecured — it has no
signature at all. There was a well-known class of vulnerability where libraries
accepted such tokens by default, letting an attacker forge any payload they
liked by simply declaring that it was not signed. If you find an endpoint that
accepts an alg: none token, that is a security finding, not a curiosity.
Also worth knowing: decoding is not verification. A decoder shows you what a token claims. Confirming that the claim is trustworthy requires the issuer’s secret or public key and belongs on the server. If you are chasing a signature problem on the other side of your stack — webhooks rather than auth — the Webhook Signature Verifier covers the HMAC case.
Handling expiry properly in a frontend
Most token bugs in a browser app are refresh bugs. A few things help:
- Refresh before expiry, not on the 401. Use
expminus a buffer of thirty to sixty seconds so a request in flight does not land after the deadline. - Handle the concurrent case. Five requests failing at once should trigger one refresh, not five.
- Never trust
expfor authorisation decisions in the client. It is a hint for scheduling a refresh. The server decides what is valid. - Do not log the token. Application logs get shipped somewhere, and a token in a log is a credential in a log.