IT
OmnvertImage • Document • Network

JWT debugging safely: decoding is not verifying

6 min read
A laptop screen showing syntax-highlighted source code in an editor, with window frames reflected in the display.

A JWT is signed, not encrypted: anyone holding it can read the payload. And until it expires, a token is a live credential that works exactly like the password behind it.

What a JWT is made of

A JWT in compact serialisation is three dot-separated parts: header.payload.signature. The first two are JSON encoded with base64url; the third is a signature over the first two. The encoding detail matters more than people expect. It is base64url, not base64: - and _ replace + and /, and the trailing = padding is stripped. That is what lets a token ride in a URL or a header without escaping, and it is why a generic base64 decoder sometimes chokes on a perfectly valid token.

The signature covers exactly the string base64url(header) + "." + base64url(payload). Change one character anywhere in it and the signature no longer matches. The consequence is the part people forget: a JWT is protected against tampering, not against reading.

Decoding is not verifying

Decoding a token is base64url in reverse. It needs no key, touches no signature, and checks no clock. A decoder will happily render a token that was signed with the wrong key, expired last March, or was issued for a different service entirely. Verifying means recomputing the signature with a key you trust, confirming the algorithm is the one you expected, and checking the time and audience claims.

A complete verification is four questions, and skipping any one of them leaves a token that looks fine and is not safe: does the signature check out against the right key; is alg on the server's allow-list; do iss and aud match what this service expects; are exp and nbf satisfied right now.

Two header fields do most of the work. alg names the signing algorithm and kid identifies the key. Both arrive inside the token, which means both are supplied by whoever sent it. Trusting them without question is the root of the classic JWT failures.

  • The none algorithm. The specification defines an unsecured JWS: set alg to none and leave the signature segment empty. A verifier that takes its algorithm from the token will accept it without checking anything. Pinning the accepted algorithms server-side is the fix.
  • Algorithm confusion. Suppose your service issues RS256 tokens and verifies them with a public key. An attacker rewrites the header to HS256 and signs the token using that same public key, which is public, as an HMAC secret. If the library reads the algorithm from the token and treats the key material as raw bytes, the signature verifies. The caller has to state the expected algorithm.
  • kid as a lookup key. If kid is concatenated into a file path or a SQL query, it becomes path traversal or injection. Resolve it only against a known set of keys.
  • Keys carried by the token. The jwk and jku headers embed a key or a URL to fetch one. Unless they are disabled or tightly allow-listed, you have built a token that verifies itself.

What belongs in the payload

A JWT is signed, not encrypted. Anyone who sees the token can read the payload: a browser extension, a forward proxy, an access log, an error-tracking service, the support ticket someone pasted it into. JWE exists for encrypted tokens, but session tokens in the wild are overwhelmingly plain signed JWS. So the contents of the payload are a privacy decision, not just a schema decision.

  • Fine: an opaque internal user id, role or scope names, a tenant id, a session id, the time claims.
  • Not fine: passwords or password hashes, national identifiers, home addresses, health or financial details, API secrets, or an access token for a different system.

The registered claims are a short list and each one has a job: iss who minted it, sub who it is about, aud who is meant to accept it, exp when it stops being valid, nbf when it starts, iat when it was issued, and jti a unique id that lets you detect replay or revoke a single token. For jti you want collision resistance rather than sequence, which is what the UUID and ULID generator is for.

Size is a practical constraint too. The token travels on every request, and a permissions array that grows with the product will eventually meet a reverse proxy with a header size limit of a few kilobytes. Choosing between embedding authorisation in the token and looking it up server-side is also a choice about whether you can revoke it.

Expiry, clock skew and revocation

exp, nbf and iat are Unix timestamps in seconds, not milliseconds. A surprising share of "this token expired immediately" bugs is that unit, usually introduced by a JavaScript service passing Date.now() straight through. To read the raw values while debugging, the timestamp converter turns them into dates you can compare against your logs.

Clocks drift. A few seconds of difference between the issuer and the verifier will reject tokens at the edge of their window for no good reason, so verifiers allow a small leeway. Thirty seconds is generous; a leeway measured in minutes quietly cancels out the short lifetime you chose.

Revocation is the harder problem. A signed token is valid on its own terms until it expires. Delete the user, change their role, disable the account: the token already issued keeps working. The usual answer is short-lived access tokens measured in minutes, a refresh token stored server-side where it can be revoked, and a jti deny-list for the emergencies where minutes are too long.

A small bunch of metal door keys lying on a dark surface, photographed in black and white.
A signature is worth exactly as much as the key behind it. Pasting a live token into someone else's decoder hands over the thing the key was protecting.

Debugging a token without leaking it

An unexpired token is a live credential. Whoever sees it can act as that user until it expires. Pasting a production token into an unknown online decoder is the same class of mistake as pasting a password.

  • Debug with a short-lived token minted in staging, not one lifted from production traffic.
  • If you had to use a real one, end that session and invalidate the token when you are done.
  • Never type a signing secret into a web form. If you must check a signature, work with the public key or a JWKS endpoint.
  • Keep tokens out of bug reports, screenshots, chat messages and log lines. Those are all searchable archives.

Pick a tool that satisfies those constraints. The JWT decoder and verifier decodes in the browser and verifies signatures through the browser's WebCrypto implementation, so the token itself is not sent anywhere; the only outbound request happens if you point it at a JWKS URL yourself. For a long payload with nested claims, the JSON viewer is easier to read than a wrapped text box.

Implementation checklist

  1. Pin the accepted algorithms server-side. none is never on the list.
  2. Pass the expected algorithm into the verification call instead of reading it from the token.
  3. Validate iss and aud, otherwise a genuine token minted for another service will pass here too.
  4. Check exp and nbf with a small, deliberate leeway.
  5. Publish keys through JWKS, resolve kid only within that key set, and plan rotation before you need it.
  6. Carry tokens over TLS only, and confirm the server side with the TLS checker.
  7. Keep sensitive data out of the payload and keep the lifetime short.

The same discipline applies next door. If you pulled an Authorization header out of a network capture, the handling rules in reading a pcap apply to the capture file as well as the token. And the gap between decoding and verifying has a direct analogue in inspecting an EXE, where reading a header is not the same as clearing the file.

Frequently asked questions

Is a JWT encrypted?

No. The tokens you meet day to day are signed JWS: protected against tampering, not against reading. Anyone can base64url-decode the payload. JWE exists for encrypted tokens but is rarely used for ordinary session tokens.

The decoded JSON looks correct. Does that mean the token is valid?

Not on its own. Decoding ignores the signature, the algorithm and the clock. Validity means the signature verifies against a trusted key, alg is on your allow-list, iss and aud match this service, and exp and nbf are satisfied.

What is the none algorithm attack?

The specification defines an unsecured JWS where alg is none and the signature segment is empty. A verifier that takes the algorithm from the token can accept such a token without checking a signature at all. Pinning accepted algorithms server-side prevents it.

Do I need to revoke expired tokens?

Expired tokens are already rejected. The problem is the unexpired ones: a signed token stays valid on its own terms even after you delete the account. Keep access tokens to a few minutes, hold refresh tokens server-side, and maintain a jti deny-list for emergencies.

Is it safe to paste a production token into an online decoder?

No. An unexpired token lets the holder act as that user. Use a short-lived staging token instead, never enter a signing secret into a web form, and if you had to use a real token, end the session afterwards.

Tools used in this post

Sources

MethodologyImage credits