🎫 Security

Reading a JWT: What the Three Segments Mean, and What a Decoder Cannot Tell You

A JWT decoder shows you the claims. It cannot tell you the token is genuine — that needs the signing key, which a decoder does not have and should not want.

A JSON Web Token is three Base64url strings joined by dots. It looks cryptographic and is mostly not: the first two segments are plain JSON that anyone can read, and only the third involves a key.

Knowing which part is which resolves most JWT confusion, including the one that matters: why a tool that happily shows you a token's contents cannot tell you whether to trust them.

The three segments

Split on the dots and you get header.payload.signature.

The header is JSON describing the token itself — the signing algorithm and the type:

{"alg":"HS256","typ":"JWT"}

The payload is JSON holding the claims — who the token is about, who issued it, when it expires, and whatever else the application put there:

{"sub":"1234567890","name":"Alice","exp":1735689600}

The signature is the only part that is not readable text. It is computed over the first two segments, using the algorithm the header names and a key the server holds.

Both JSON parts are Base64url-encoded, not encrypted. You can decode them with any Base64 tool, or read them by eye once you recognise that a JWT payload usually starts eyJ — which is what {" encodes to.

The registered claims worth recognising

RFC 7519 reserves seven three-letter claim names. They are abbreviated to keep tokens small, since a JWT often travels in a header on every request.

  • iss — issuer. Who created the token.
  • sub — subject. Who it is about, usually a user ID.
  • aud — audience. Who it is for. A service should reject tokens not addressed to it, even validly signed ones.
  • exp — expiry. A Unix timestamp in seconds.
  • nbf — not before. The token is invalid until this time.
  • iat — issued at. When it was created.
  • jti — JWT ID. A unique identifier, used for revocation lists.

The three timestamps are the common source of off-by-1000 bugs: they are seconds since the Unix epoch, while JavaScript's Date.now() returns milliseconds. An exp compared against an unconverted Date.now() will look like it expired in 1970.

Decoding is not verifying

This is the distinction that matters, and it is worth stating bluntly: reading a JWT tells you what it claims, not whether the claim is true.

Anyone can take a valid token, change "role":"user" to "role":"admin", re-encode the payload and reassemble the three parts. The result decodes perfectly. Every field displays correctly in any decoder. It is forged.

What catches it is signature verification, and that needs the key:

  • HS256 and the other HMAC algorithms use one shared secret for both signing and verifying. If you can verify, you can also forge.
  • RS256 and ES256 use a private key to sign and a public key to verify. Services can check tokens without being able to issue them, which is why this is the right default for anything distributed.

A browser-based decoder has neither key, and should not ask for one — pasting a signing secret into a web page is a far worse idea than the problem it solves. So a decoder can honestly tell you two things: what the claims say, and whether exp has passed. It cannot tell you the token is genuine.

alg:none, and why the header is not trustworthy

The specification includes an algorithm called none, meant for tokens whose integrity is guaranteed by some other layer. It takes an empty signature.

The attack writes itself. Take a real token, set the header to {"alg":"none"}, edit the payload freely, drop the signature and leave the trailing dot. A library that reads the algorithm from the token and dispatches on it will conclude there is nothing to verify and accept whatever the payload says.

A second variant is the algorithm confusion attack. A server expecting RS256 holds a public key. Send it an HS256 token signed using that public key as the HMAC secret. If the library picks the algorithm from the header and the key from its configuration, it will verify an attacker-signed token — because the public key is, by definition, public.

Both have the same root cause: trusting a field in the untrusted part of the token to decide how to check the trusted part. The fix is the same for both — the verifying code specifies the expected algorithm, and rejects anything else outright. Most libraries now require this, but older code and hand-rolled verification often do not.

What to put in a payload, and what not to

Because the payload is readable by anyone holding the token, it is the wrong place for anything sensitive. Not hashed, not obfuscated — simply absent.

Safe: user IDs, roles, tenant identifiers, expiry, scopes. Things the bearer already knows or may as well know.

Not safe: email addresses you would not expose, internal system identifiers, anything under a data-protection obligation, and above all nothing resembling a credential.

Size is the other constraint. A JWT usually rides in an Authorization header on every request, and many servers cap header size at 8 KB. Each claim is paid for on every call. Tokens that accumulate permission lists get large quickly, which is a common reason to keep a reference token and look up permissions server-side instead.

Finally, revocation. A signed JWT is valid until it expires, by design — that is what makes it stateless. There is no way to recall one. The practical approach is short expiry times (minutes, not days) with refresh tokens, or a jti checked against a denylist, which trades away the statelessness you adopted JWTs for.

Inspecting a token safely

The JWT decoder splits a token, decodes both JSON segments, pretty-prints the claims and reports whether exp has passed. It handles UTF-8 claims correctly — a name with an accent in it is a common way to find out that a decoder is reading bytes as Latin-1.

It does not verify the signature, and says so. That limitation is honest rather than regrettable: verification needs the signing key, and the right place for that is your server, not a web page.

Everything happens in your browser, which matters here more than for most tools. A JWT is frequently a live credential. Pasting one into a page that sends it anywhere is handing over a working session, and "it was just a decoder" is not a defence.

If you want to inspect the segments by hand, the Base64 decoder in URL-safe mode does the same job one piece at a time, and the hash generator is useful for checking an HMAC if you are debugging a signature locally.

Frequently asked questions

Is a JWT payload encrypted?

No. It is Base64url-encoded JSON, readable by anyone holding the token. The signature prevents modification, not reading. Never put anything in a payload that the bearer should not see.

Can a decoder tell me whether a JWT is valid?

Only partially. It can decode the claims and check whether exp has passed. Confirming the token is genuine requires the signing key to verify the signature, which a browser-based decoder does not have — and should not ask for.

What is the alg:none attack?

An attacker sets the header to {"alg":"none"}, edits the payload and omits the signature. A library that chooses its verification algorithm from the token's own header concludes there is nothing to check. The fix is for the verifying code to specify the expected algorithm and reject anything else.

Why does my exp check think the token expired in 1970?

JWT timestamps are in seconds since the Unix epoch; JavaScript's Date.now() returns milliseconds. Compare against Math.floor(Date.now() / 1000), or multiply exp by 1000 before building a Date.

How do I revoke a JWT?

You largely cannot — statelessness is the trade-off. The usual approaches are short expiry times with refresh tokens, or checking the jti claim against a denylist, which reintroduces the server-side state JWTs were adopted to avoid.

Everything on ToolYard runs in your browser. No uploads, no accounts, no limits.

Browse all tools →