JWT Decoder

Take a JSON Web Token apart, read every claim, and verify the signature right here

Nothing here is put in the address bar or remembered between visits. A token is a password until it expires, and a link carrying one ends up in history, in bookmarks and in anything reading the referrer.

Header

-

Payload

-

What the claims mean

Paste a token and every claim in it is listed here.

Signature

Three pieces of Base64url with full stops between them. The first two are JSON, readable by anybody. The third is a signature over the first two, character for character exactly as they were written.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjMifQ . 3sT9r_...
+------------ header ------------+   +-- payload --+   + signature +

header      { "alg": "HS256", "typ": "JWT" }
payload     { "sub": "123" }
signature   HMAC-SHA256( header64 + "." + payload64, key )

That last detail matters more than it sounds. The signature covers the encoded text, not the decoded meaning. That is why a token cannot be re-serialised, re-indented or re-ordered and still verify. Whatever produced it chose a byte sequence, and only that byte sequence has a valid signature.

Decoding is not verifying, and Base64 is not encryption

{ "sub": "123", "admin": false }   ->  eyJzdWIiOiIxMjMiLCJhZG1pbiI6ZmFsc2V9
{ "sub": "123", "admin": true  }   ->  eyJzdWIiOiIxMjMiLCJhZG1pbiI6dHJ1ZX0

Both decode. Only one has a signature that checks out.

Anybody holding the token can read every claim in it. There is no key involved in decoding and there never was. Which means a JWT is the wrong place for anything you would not print: no internal user table IDs you would rather not leak, no roles you are embarrassed by, and absolutely no secrets. If the content genuinely must be hidden, the format for that is JWE, which has five segments, not three, and encrypts the payload.

What the signature buys is that the claims have not been altered since they were issued. That is worth a great deal, and it is worth nothing at all if the verifier does not check it.

Four ways a verifier gets fooled

AttackHow it works
alg: noneThe header says the token is unsigned, and a library that honours it returns the claims without checking anything. Every claim then comes from whoever sent the request.
RS256 to HS256The header is changed to HS256 and the token is signed with the server's RSA public key as the HMAC secret. A verifier that takes the algorithm from the token instead of from its own configuration will happily check it and pass.
kidThe key id is used to look up a key, often by file path or database query. It is attacker-controlled input, and it has been used for path traversal, SQL injection and pointing the verifier at a file whose contents the attacker knows.
jku and jwkThe header can carry a URL for the key set, or the key itself. Both mean "trust this key", supplied by the person you are trying to authenticate. Neither should ever be followed without an allowlist.

The rule underneath all four is the same one: the algorithm and the key are the verifier's decision, not the token's. RFC 8725 is the whole list of best current practice and is short enough to read in one sitting.

exp is in seconds, and the difference is thirty thousand years

Every time in a JWT is a NumericDate, which RFC 7519 defines as seconds since 1970. JavaScript's Date.now() gives milliseconds, and the mistake is one keystroke:

"exp": 1700000000        14 November 2023, 22:13:20 UTC
"exp": 1700000000000     year 55839, and every verifier will believe it

A token with a millisecond exp never expires, which is exactly the failure nobody notices, because everything keeps working. Times that look like milliseconds are called out above, not rendered as a date in the year 55839.

In the other direction, exp is compared against the verifier's clock, not yours. Almost every library allows around sixty seconds of skew by default, so a token four seconds past its expiry is usually still accepted. That is what the "just past its expiry" note above means.

The claims nobody checks

A valid signature says the token is genuine. It does not say the token is for you.

Signature lengths, and what they tell you

algSignatureNotes
HS25632 bytes, 43 charactersShared secret. Anyone who can verify can also forge
HS51264 bytes, 86 charactersSame trade, longer hash
RS256256 bytes, 342 characters2048-bit RSA. The length is the key size
PS256256 bytes, 342 charactersRSA again, with a modern padding scheme
ES25664 bytes, 86 charactersP-256. Two 32-byte halves, r then s
ES512132 bytes, 176 charactersP-521, so 66 bytes each half, not 64
EdDSA64 bytes, 86 charactersEd25519

A signature that is the wrong length for its algorithm is a truncated token, and it is flagged above. One other trap lives here: the ECDSA signature in a JWT is the raw r and s values side by side, not the DER structure that OpenSSL and most command-line tools produce. Converting between the two is a step people forget and then spend an afternoon on.

Base64url, not Base64

The alphabet swaps + for - and / for_, and drops the = padding, so that a token can sit in a URL or a header without being escaped. A token containing+, / or = was produced by something using the wrong alphabet, and strict verifiers reject it. That is called out above too.

The thing you give up

A signed token is checked without asking anybody. That is the whole attraction, and it is also the whole problem: there is nothing to ask, so there is nowhere to say "this one is cancelled". Logging out, changing a password, or revoking a role does not affect a token already issued.

Which leaves the usual three answers, all of them compromises. Keep the expiry short and refresh often, which puts the request back on the server more or less as often as a session cookie would. Keep a deny list ofjti values, which is a database lookup and gives back the statelessness you were buying. Or accept the window and design around it. A token with an expiry measured in days has effectively been issued for days.

Size is the other quiet cost. A token goes on every request, cookies cap out at 4 KB each, and most servers refuse a request header much past 8 KB. Every role, group and permission stuffed into a payload is paid for on every call.