tooldura

Developer Tools

Inside a JWT: Why Decoding a Token Proves Nothing

T
tooldura editorial
9 min readUpdated August 18, 2026Open tool →

A support engineer pastes an access token into a decoder, sees `"role": "admin"`, and concludes the user is an admin. The claim is right there in readable JSON, so the inference feels safe. It is not: the same JSON is readable because it was never hidden, and it is trustworthy only if something checked the third section of the token, which the decoder was never given the key to do.

Three Sections, Two of Them Public

A signed JWT is three base64url strings joined by dots, described by RFC 7515 as the compact serialisation of a JWS.

The first section is the header, a small JSON object naming the algorithm the signature was made with and, usually, the media type. The second is the payload: the claims, again as plain JSON. The third is the signature, which is raw bytes rather than text, and is the only part that is not human-readable after decoding.

Base64url is an encoding, not a cipher. It exists because the token has to survive being put in a URL, a cookie or an HTTP header without any character needing escaping, so RFC 4648 section 5 swaps + and / for - and _ and drops the padding. Reversing it takes no key and no permission, which is why the first two sections of every JWT you have ever issued should be treated as published.

The practical consequence is short: never put anything in a payload that the holder of the token should not see. Internal user ids, feature flags and roles are fine. Email addresses of other people, internal hostnames, anything with a compliance boundary around it, are not.

The Claims RFC 7519 Actually Defines

Everything else in a payload is the issuer's own invention, however official the name looks.

ClaimMeaningWhat a receiver should do with it
issIssuerMatch against the one issuer you expect, not a list of anything plausible
subSubjectTreat as the user id, unique only within that issuer
audAudienceReject the token if your own identifier is not in it
expExpires atReject at or after this time, allowing a small clock skew
nbfNot beforeReject until this time
iatIssued atUseful for age limits; not an expiry on its own
jtiJWT idStore it if you need to stop a token being replayed
🔍

Decoding answers what the token says, not whether it is true

Decoding is base64 in reverse and needs nothing from you. Verifying recomputes the signature over the header and payload with a key, and compares. Only the second step tells you the claims are the ones the issuer wrote. Every finding that begins "we read the user id from the token" and ends badly sits in the gap between those two sentences.

What the Signature Settles, and What It Does Not

The signature covers exactly one string: the header and payload sections, still encoded, joined by a dot. Change a single character in either and the recomputed signature stops matching.

That gives you integrity and origin. Nobody without the key can alter the claims, and nobody without the key can produce a token that verifies. Both properties are worth having and both are narrow.

What the signature does not give you is any statement about time, audience or revocation. A signature stays valid forever, because mathematics has no opinion about the clock. A token that expired last March still has a perfect signature; it is the exp check, done separately and after verification, that makes it useless. The same goes for aud: a token minted correctly for a different service in your estate is fully authentic and still must be refused.

This is why RFC 8725, the best-practices document published in 2020 for exactly these mistakes, treats signature verification and claims validation as two obligations rather than one.

How Verification Gets Skipped by Accident

Almost nobody decides to trust an unverified token. It happens through defaults and convenience functions.

1

Calling the decode helper instead of the verify one

Most libraries ship both. In the Node `jsonwebtoken` package they are `decode()` and `verify()`, and the first takes no key, so it always works and never complains. It is the natural thing to reach for while debugging and the easy thing to leave in.

2

The "none" algorithm

RFC 7515 defines an unsecured JWS whose alg is "none" and whose signature is empty. Tim McLean's March 2015 disclosure showed several libraries would accept one when the caller had asked for a real algorithm, so an attacker could strip the signature and rewrite the payload. Modern versions refuse, but the shape of the bug survives anywhere the token gets to nominate its own treatment.

3

Letting the header pick the algorithm

The same disclosure covered the more elegant version: take a service that verifies RS256, hand it a token whose header says HS256, and signed with the RSA public key as if it were an HMAC secret. The public key is public, so the attacker has it. The fix is in RFC 8725 section 3.1 and it is blunt: the verifier decides the algorithm from its own configuration, and a token naming anything else is rejected before its signature is even considered.

4

Fetching the key from wherever the token points

The `jku` and `x5u` header parameters carry URLs. A verifier that fetches them without an allowlist will happily fetch the attacker's key set and confirm the attacker's signature. The same applies to `kid` when it is interpolated into a filesystem path or a SQL query.

5

Verifying, then never checking exp or aud

The library returned a payload without throwing, so the token is good. It is authentic; whether it is current, and whether it was meant for this service at all, are separate questions that nothing asked.

Read a token and check its signature

Claims in plain English, HMAC and public key verification, all in the browser.

Open the JWT Decoder →

The Timestamps Are Seconds, and This Catches Everyone

RFC 7519 section 2 defines exp, nbf and iat as a NumericDate: the number of seconds since 1970-01-01 UTC, ignoring leap seconds.

JavaScript's Date.now() returns milliseconds. Writing it straight into exp produces a number a thousand times too large, which places the expiry roughly fifty thousand years out. The token is well formed, every library accepts it, and it never expires. Nothing in the system reports an error, because from its point of view nothing is wrong.

The reverse mistake is quieter still. Reading a NumericDate and passing it to new Date() without multiplying by 1000 gives you a moment in January 1970, so the token looks long expired and the failure gets blamed on clock configuration.

A decoder that renders these numbers as real dates turns both bugs into something you can see in a second, which is most of the reason to look at a token in a tool rather than a log line.

Tokens Grow, and Headers Have Limits

Because the payload is JSON and base64 adds a third on top, a JWT is considerably larger than the session id it usually replaces. A token carrying a handful of registered claims lands around 300 to 500 characters. Add a permissions array, a tenant, a set of feature flags and a profile picture URL and a few kilobytes is easy.

That matters because the token normally travels in the Authorization header on every single request. nginx allocates 8 KB for large request headers by default through large_client_header_buffers, and various proxies and API gateways sit below that. The failure is a 431 or a 400 from an intermediary rather than from your application, which makes it awkward to diagnose from inside the code.

If a token is heading past a kilobyte, the usual answer is to stop shipping the whole profile in it: keep identity and coarse authorisation in the token, and look the rest up by sub when a request needs it.

Frequently Asked Questions

Related Tools

Keep Reading