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 ·signature
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
| Attack | How it works |
|---|---|
| alg: none | The 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 HS256 | The 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. |
| kid | The 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 jwk | The 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 itA 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.
- aud is who the token was issued for. A token minted for a different client of the same identity provider will have a perfectly valid signature against the same public key.
- iss is who issued it. Without checking it, any provider whose keys you trust can mint a token for any subject.
- sub is the stable identifier. Email addresses are not: they change, and some providers reuse them.
- azp is the party the token was actually issued to, which matters when
audcontains more than one value.
Signature lengths, and what they tell you
| alg | Signature | Notes |
|---|---|---|
| HS256 | 32 bytes, 43 characters | Shared secret. Anyone who can verify can also forge |
| HS512 | 64 bytes, 86 characters | Same trade, longer hash |
| RS256 | 256 bytes, 342 characters | 2048-bit RSA. The length is the key size |
| PS256 | 256 bytes, 342 characters | RSA again, with a modern padding scheme |
| ES256 | 64 bytes, 86 characters | P-256. Two 32-byte halves, r then s |
| ES512 | 132 bytes, 176 characters | P-521, so 66 bytes each half, not 64 |
| EdDSA | 64 bytes, 86 characters | Ed25519 |
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.