Decoding a JWT Is Not Verifying It
A JWT decoder reads the payload without the key, because the payload is only Base64. What proves a token is real, and what does not.
Table of contents
- Key takeaways
- What is actually inside a JWT?
- What is the difference between decoding and verifying a JWT?
- Your library already names the difference
- Why is trusting the alg header dangerous?
- What does RFC 8725 actually require?
- What an algorithm-confusion attack looks like step by step
- Which claims still need checking after the signature passes?
- Why is my token expired when the timestamp looks right?
- Choosing the key without letting the token choose it
- A verification checklist
- Where a decoder belongs
- The short version
- Frequently asked questions
- Is it safe to use a JWT decoder website?
- Can I encrypt a JWT so the claims are hidden?
- Do I need to check exp if my tokens are short-lived?
- What is the difference between iat and nbf?
- Why does my token fail immediately after being issued?
- Related reading
- References
Paste a token into a JWT decoder and the claims appear immediately. No key, no secret, no server. That is not the decoder being clever. Every JWT carries its payload in plain sight, and reading it proves nothing about who issued it.
That distinction, decoding versus verifying, is the root of most JWT vulnerabilities that have ever shipped. It is also the reason a decoder is a legitimate debugging tool rather than a security hole.
Key takeaways#
- A JWT's three parts are Base64url-encoded, not encrypted. Anyone holding the token can read every claim.
- Decoding answers "what does this say". Verifying answers "was this signed by the key I trust". Only the second requires a secret.
- RFC 8725 says libraries "MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms".
- The
algheader is a claim by the token about itself. Trusting it is how thenoneand algorithm-confusion attacks work. exp,nbfandiatare seconds since the epoch, not milliseconds.
What is actually inside a JWT?#
A JWT is three Base64url strings joined by dots: header, payload, signature. The header names the signing algorithm and often a key id. The payload holds the claims. The signature is a MAC or digital signature computed over the first two parts, which is a different job from hashing a password even though both reach for SHA-256.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <- header
.eyJzdWIiOiIxMjMiLCJleHAiOjE3NjkxMjM0NTZ9 <- payload
.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk <- signatureDecode the first segment and you get {"alg":"HS256","typ":"JWT"}. Decode the second and you get the claims. Neither step needs a key.
Base64url is a transport encoding, chosen so the token survives a URL or an HTTP header intact. It has no key and hides nothing, the same way Base64 is not encryption anywhere else it appears.
Split a token on the dots and decode the middle segment with any Base64 decoder and the claims come out. That is the entire mechanism behind every JWT decoder, including the one on this site.
What is the difference between decoding and verifying a JWT?#
Decoding reverses Base64url and hands you the claims. Verifying recomputes the signature over the header and payload using a key you already trust, then compares it to the signature in the token. Decoding needs nothing. Verification needs the key, and it is the only step that establishes the token is genuine.
Put another way: a decoded token tells you what someone wants you to believe. A verified token tells you that a specific key vouched for it.
Two things follow. A decoder cannot validate a token, so a green checkmark next to "decoded" means only that the Base64 was well formed. And a forged token decodes exactly as cleanly as a real one, because forging the payload is trivial and only the signature is hard.
Your library already names the difference#
Every mature JWT library ships two functions, and their names are the argument this article is making. One takes no key. The other refuses to run without one.
In Node, with jsonwebtoken:
// Reads the claims. Proves nothing.
const claims = jwt.decode(token);
// Establishes the token is genuine.
const claims = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // never read from the token
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 60, // seconds
});Auth0's README is blunt about the first one: it "will not verify whether the signature is valid. You should not use this for untrusted messages." It adds that a decoded payload from an untrusted source "should be treated like any other user input", which is the right mental model — a decoded claim has exactly the trust level of a form field.
In Python, with PyJWT:
# Reads the claims. Proves nothing.
claims = jwt.decode(token, options={"verify_signature": False})
# Establishes the token is genuine.
claims = jwt.decode(
token,
key=public_key,
algorithms=["RS256"], # hard-coded, not derived
issuer="https://auth.example.com",
audience="https://api.example.com",
leeway=60,
)PyJWT's documentation states the rule directly: "Do not compute the algorithms parameter based on the alg from the token itself... as that might expose you to various vulnerabilities." Its recommendation is to "hard-code a fixed value for algorithms, or configure it in the same place you configure the key."
Why is trusting the alg header dangerous?#
The alg header is written by whoever produced the token, which on an attack path is the attacker. A verifier that reads alg and picks its algorithm accordingly is letting the input choose how it will be checked. Both classic JWT attacks exploit exactly that.
The none algorithm. RFC 7519 section 6 defines an Unsecured JWT as "a JWS using the 'alg' Header Parameter value 'none' and with the empty string for its JWS Signature value." Set alg to none, drop the signature, and a verifier that honours the header has nothing left to check. RFC 8725 answers this directly: JWT libraries "SHOULD NOT consume JWTs using 'none' unless explicitly requested by the caller."
Algorithm confusion. RFC 8725 section 2.1 describes it precisely. Change RS256 to HS256 and the verifier will "validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret." The public key is public, so the attacker knows the HMAC secret and can sign whatever they like. An asymmetric setup collapses into a symmetric one the attacker controls.
Both attacks share one shape: the token told the server how to verify itself, and the server listened.
What does RFC 8725 actually require?#
Published in February 2020, RFC 8725 is the Best Current Practices document for JWT, and section 3.1 is the part that closes this class of bug. Three requirements matter most, and they are stated as MUSTs.
- Pin the algorithms. Libraries "MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations." The allowlist lives in your configuration, not in the token.
- Cross-check the header. The library "MUST ensure that the
algorencheader specifies the same algorithm that is used for the cryptographic operation." A mismatch is a rejection, not a negotiation. - One key, one algorithm. "Each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed." That is what makes an RSA public key unusable as an HMAC secret.
For most libraries this is one argument. Pass the algorithm explicitly on every verify call, even when the default looks correct, because the default is what changes during a dependency upgrade.
What an algorithm-confusion attack looks like step by step#
Following the mechanics once makes the RFC's MUSTs feel inevitable rather than pedantic. Assume a service issuing RS256 tokens: it signs with a private key and verifies with the matching public key, which it publishes at a JWKS endpoint like any OAuth provider.
- Read a legitimate token. No key needed. Base64url decode gives the header
{"alg":"RS256","kid":"k1"}and the claims. - Fetch the public key. It is public by design, served from the provider's JWKS URL.
- Edit the claims. Change
subto another user's id, or addrole: admin. - Change
algtoHS256. The header now claims the token is symmetrically signed. - Sign with the public key as the HMAC secret. RFC 8725 section 2.1 describes the outcome: the verifier will "validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret."
- Send it. A verifier that takes its algorithm from the header computes HMAC-SHA256 over the token using the public key, gets the same value the attacker got, and accepts.
At no point was the private key involved. Nothing was brute-forced. This works because the verifier accepted a claim about verification from the thing being verified.
Step 4 is the only step that a fixed algorithms: ['RS256'] breaks, and breaking one step is enough. The verifier never reaches the HMAC path, so the forged signature is checked against RSA and fails.
Shorter still is the none variant: set alg to none, truncate the token after the second dot, and a verifier that honours the header has nothing to compare. RFC 7519 section 6 defines this as a legitimate token type — an Unsecured JWT — which is exactly why RFC 8725 says libraries "SHOULD NOT consume JWTs using none unless explicitly requested by the caller."
Which claims still need checking after the signature passes?#
Signature validity proves the token was issued by the right key. It says nothing about whether the token is still current, was meant for your service, or was meant for the request you are handling. Those are separate checks, and libraries vary in how many they perform by default.
| Claim | Means | Check |
|---|---|---|
exp | Expiration time | Now must be before it |
nbf | Not before | Now must be at or after it |
iat | Issued at | Use for token age and replay windows |
iss | Issuer | Must equal the issuer you configured |
aud | Audience | Must contain your service's identifier |
sub | Subject | The user id — authorise on it, don't trust it blindly |
aud is the one most often skipped. Without it, a token minted for one service in your estate is accepted by every other service that shares the signing key, which turns a low-privilege token into a high-privilege one. The wider trade-offs between tokens, sessions and API keys are in API authentication compared.
Why is my token expired when the timestamp looks right?#
Because exp is in seconds and something in the chain used milliseconds. RFC 7519 defines NumericDate as "the number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap seconds". A JavaScript Date.now() value is 1,000 times too large and lands roughly 50,000 years in the future.
What makes this confusing is that the failure is asymmetric. A millisecond exp never expires, so tests pass and the bug ships. A millisecond nbf is always in the future, so every token is rejected immediately and the same root cause looks like a completely different problem. It is the same seconds-versus-milliseconds trap that breaks Unix timestamps everywhere else.
Two related sources of "expired" tokens that are not encoding bugs:
- Clock skew. RFC 7519 permits leeway on both
expandnbf, "usually no more than a few minutes, to account for clock skew". If your services disagree by thirty seconds, a freshly issued token can failnbfon arrival. Configure the leeway rather than widening the lifetime. - Timezone confusion. NumericDate is UTC by definition. A server converting a local time to a timestamp before signing produces a token that is wrong by its own offset.
Pasting the number into a timestamp converter settles which unit you are looking at in about two seconds, and the JWT parser shows the claims with the times already resolved.
Choosing the key without letting the token choose it#
Rotation means a service has several valid keys at once, so a verifier does need to pick one per token, and the kid header is how. Reading kid is correct. What matters is where the lookup lands.
Safe: kid is a lookup key into a key set you fetched from a URL in your own configuration, cached, and refreshed on a schedule. An unknown kid is a rejection.
Unsafe: any path where the token influences which bytes become the key.
jkuorx5ufrom the token. These headers carry a URL. Honouring them lets the attacker host the key set that verifies their own signature.jwkembedded in the header. The token supplies its own public key. Self-signed, self-verified, meaningless.- A
kidinterpolated into a filesystem path or a query.kidis attacker-controlled text, sokeys/${kid}.pemis a path traversal and a SQL string is an injection. - Falling back to a default key when
kidis missing. A missingkidis malformed input, not a request for the default.
The rule collapses to one sentence: the token may tell you which of your keys to use, never what the key is or where to get it.
A verification checklist#
Everything below runs server-side, on every request, in this order:
- Reject
alg: noneunconditionally. - Pass an explicit algorithm allowlist to the verify call.
- Verify the signature against a key selected by
kidfrom your own key set, never from the token's contents. - Check
expandnbfwith a configured leeway of a minute or two. - Check
issandaudagainst configured values. - Authorise on the claims only after all of the above passed.
Step 3 is the subtle one. Reading kid to pick a key is normal and correct; fetching a key from a URL in the token is not, because the attacker chooses the URL and therefore the key.
Where a decoder belongs#
Treat a decoder as what it is: a debugging tool. It answers questions verification cannot: which issuer minted this, is the audience what I configured, is that exp in seconds, did the claim I added actually survive the login flow. All of that is faster to read than to log.
What it cannot do is tell you a token is trustworthy, and the fact that it needs no key is the proof. Since the payload is readable by anyone holding the token, decode it somewhere it does not leave your machine. Everything on TweakPad runs in the browser, which for a bearer token is the difference between inspecting a credential and uploading one.
The short version#
Decoding a JWT is Base64, and Base64 is not a security boundary. Verifying is a signature check against a key you chose in advance, using an algorithm you named in advance, followed by claim checks the signature knows nothing about.
Read the token with a decoder when you are debugging. Trust it only after alg came from your configuration, exp and nbf were compared in seconds, and iss and aud matched what you expected. Same token, two entirely different questions.
Frequently asked questions#
Is it safe to use a JWT decoder website?#
It depends entirely on where the decoding happens. A decoder that runs in your browser never transmits the token; one that posts it to a server has been handed a live credential. Check for a network request before pasting anything from production, and prefer a local tool or jwt.decode in a REPL for real tokens.
Can I encrypt a JWT so the claims are hidden?#
Yes, with JWE rather than JWS. A signed JWT (JWS) is readable by anyone holding it; JWE encrypts the payload so only a key holder can read it. Most systems that reach for JWE would be better served by keeping secrets out of the token entirely.
Do I need to check exp if my tokens are short-lived?#
Yes. "Short-lived" is a property of how you issue tokens, not of how you accept them, and an attacker replaying an old token is not bound by your issuance policy. The check is one option in every library.
What is the difference between iat and nbf?#
iat records when the token was issued and is informational; you use it to measure token age. nbf is a constraint the verifier must enforce: the current time has to be at or after it. A token can be issued now and valid from an hour from now.
Why does my token fail immediately after being issued?#
Usually clock skew on nbf. RFC 7519 permits leeway of "usually no more than a few minutes, to account for clock skew", so configure a tolerance of a minute or two rather than widening the token lifetime. The other common cause is a millisecond nbf, which places the token permanently in the future.
Related reading#
- Base64 Is Not Encryption — the same lesson one layer down
- Unix Timestamps: Seconds vs Milliseconds — why
expbreaks - API Authentication Compared — when a JWT is the wrong choice
- Why SHA-256 Is Wrong for Passwords
- Inspect a token in the browser with the JWT Decoder or the JWT Parser, and check a claim's units with the Timestamp Converter.
References#
Tags
- Security
- JWT
- Authentication
- API
- Encoding