Skip to main content
Security

Decoding JWTs without sending them anywhere they should not go

Every developer has done the thing: copy a token from the network tab, paste it into the first "JWT decoder" that a search suggests, and eyeball the payload. The token usually belongs to a staging environment and nothing bad happens. Then one day it is a production token with a customer ID in it, and the habit does not feel so harmless.

A JWT is three base64 blobs, not encryption

Nothing in a JWT is encrypted by default. The header and payload are base64url-encoded JSON. Anyone who has the token can read every claim in it. The signature only guarantees that the payload was not modified by someone without the signing key. This is worth internalizing, because it changes how you treat tokens in logs, screenshots, and yes, third-party websites.

Decode it yourself in two lines

Splitting on dots and base64url-decoding the first two segments is the whole trick:

const [h, p] = token.split('.');
const payload = JSON.parse(atob(p.replace(/-/g, '+').replace(/_/g, '/')));

Or use a tool that does the same thing locally in your browser: paste the token into the JWT decoder and the parsing happens on your machine, nothing is transmitted. Decoding never verifies the signature, which is exactly what you want for reading claims. Verification needs the key and belongs in your application code or a library, not in a web tool.

The claims worth checking

Once you can read the payload, these are the fields that answer most debugging questions:

  • exp and iat as Unix seconds. iat in the future usually means clock skew between the auth server and your server.
  • iss and aud. Mismatches here are the classic "token is valid but my middleware rejects it" bug.
  • sub, to confirm the token actually belongs to the user you think it does.
  • Custom claims your app added. Roles and scopes live here, and stale cached roles are a common source of "the fix works locally but not in prod".

Common failure modes

Most JWT bugs are not cryptography. They are one of these:

  • Comparing exp against the wrong clock or ignoring clock drift entirely.
  • Trusting claims from an alg: none token, which some old libraries happily accept.
  • Long-lived tokens because revocation is annoying. Access tokens should be short, and refresh should be the durable part.
  • Putting sensitive data in the payload and then wondering why it shows up in logs, browser extensions, and paste sites.

What the header tells you

The header lists the algorithm and usually a kid (key ID). If a provider rotates keys, a mismatch between the kid in your token and the keys your server has cached explains a lot of "worked yesterday" incidents. The decoder shows the header alongside the payload, which makes that check take two seconds.

When the token you are staring at is not a JWT but some opaque session string, the encoding tools handle plain base64 and URL-encoded payloads the same way: locally, nothing leaves the page.

The habit to keep

Decode locally, treat every claim as public, verify signatures in code with a real library, and keep token lifetime short. The tokens will still leak sometimes. When they do, at least they were short-lived and contained nothing you would have to explain to a customer.