Part of: Browser-Based Developer Tools: Why Client-Side Beats Uploading

Debugging JWTs Client-Side: Header, Claims, Expiry and Privacy

A JSON Web Token split into its header, payload and signature segments with the expiry claim highlighted

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:

  1. 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.
  2. Prefer an expired or test token. For debugging a claim structure, an expired token tells you everything a live one would.
  3. 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 exp minus 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 exp for 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.

← All articles