JWT Validation: Decoding a Token Is Not Verifying It

JWT validation is the set of checks that prove a token was issued by your authorization server and still applies to this request: the signature verifies under an expected algorithm and key, the issuer and audience match, and the token has not expired. Decoding does none of that. It reads the claims and hands them over.

The two operations sit one line apart in most libraries. They return objects that look identical. Only one of them is a security control.

Key takeaways

  • Every JWT is readable by anyone holding it. Base64url is encoding, not encryption, and the claims inside are plain text to the caller.
  • The alg header travels inside the token, so the attacker picks it unless the server refuses to look. The fix is not to validate that header. It is to stop consulting it.
  • A valid signature is one assertion, not five. Issuer, audience, and expiry are separate questions, and each gets its own recommendation in RFC 8725.
  • "The library said yes" is a claim about a code path, not about mathematics. Platform patch level is part of your authentication posture.
  • A signature proves possession of a key. If the key is guessable, shared, or shipped in the product, the proof is worth what the key is worth.

Two operations that look like one

A JWT is three base64url segments joined by dots: a header, a payload of claims, and a signature. The first two are not encrypted. Anyone holding the token can read sub, role, and tenant_id without a key and without permission. That is by design, and it is not the problem.

The problem is a server that does the same thing and calls it authentication.

Most libraries expose both operations, named something close to decode and verify. decode splits the token and parses the JSON. verify does that too, and then checks the signature against a key you supply. Both return a claims object, and three lines later the code is reading claims.role and deciding what to show. Nothing downstream can tell which call produced it.

Decoding tells you what the token says. Validating tells you who said it. Reaching for the first when you needed the second is not an exotic mistake. It is a plausible line of code that passes every test you would think to write, because in testing the token always came from you.

The algorithm is a field in the message

The header of a JWT names the algorithm used to sign it. That field arrives from the caller, inside the object whose authenticity is in question. A library that trusts it has handed the attacker the choice of how the attacker's own work gets checked.

RFC 8725, JSON Web Token Best Current Practices (BCP 225), opens its threat section here. Set alg to none and, in the RFC's words, "some libraries would trust this value and 'validate' the JWT without checking any signature." The token is unsigned, the check is skipped, and the request proceeds as whoever the payload says it is.

The second variant is subtler. Take a token signed with RS256, change the header to HS256, and a naive implementation will "try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret." Asymmetric verification uses a public key; symmetric verification uses a secret. Swap the declared algorithm and the library treats one as the other, so the attacker signs whatever they like with a key that was published on purpose.

Neither attack is clever. Both exist because the message was allowed to describe its own verification. So the fix is not to validate the alg header, it is to stop consulting it: the server knows which keys it trusts and which algorithms those keys are for, and a token whose header disagrees is rejected before any signature math runs.

When a passing check still lies

Two failure modes survive a correctly pinned algorithm, and they are worth knowing because neither produces an error.

The first is a verification that runs and means nothing. CVE-2022-21449, disclosed by Neil Madden as Psychic Signatures, was a Java bug where ECDSA verification never checked that the signature values r and s were non-zero. Send both as zero and "Java would accept it as a valid signature for any message and for any public key." Applications above that line did everything right: pinned the algorithm, supplied the correct key, called verify. It returned true. Java 15 through 18 were affected until the April 2022 Critical Patch Update.

The second is a key that was never a secret. RFC 8725 flags weak symmetric keys because an attacker holding a token can brute-force candidates offline, without touching your service or tripping a rate limit, which is why an HMAC secret a human chose and typed is not a key. The extreme version is a key you shipped: Cisco's advisory for CVE-2025-20188 scored 10.0 for unauthenticated root access on IOS XE Wireless LAN Controllers, and the cause was a hard-coded JWT. Verification on those devices worked exactly as designed. Everyone just had the key.

A verified signature answers exactly one question

Suppose the signature genuinely verifies under a key you genuinely trust. You now know the token was minted by that issuer and has not been altered. You do not yet know it was minted for you, or that it is still current.

Those are separate assertions, and the RFC gives each its own recommendation: validate issuer and subject, use and validate audience, do not trust received claims. The threat behind the audience check is cross-JWT confusion, where a token legitimately issued to one service is presented to another that shares the same signing authority. Every cryptographic check passes. The token is real. It was simply never meant for this door, and the only thing that catches it is a service that knows its own name and insists on seeing it.

Expiry is the same problem pointed at time. Without an enforced exp, a token issued once is a credential forever, and every control that assumed sessions end quietly stops being true.

What JWT validation should actually assert

Five assertions make up validation, and a sixth has to happen after it. Each answers a different question.

AssertionQuestion it answersWhat skipping it looks like
Algorithm chosen server-side, from the keyWhich math, decided by whom?alg: none, or a public key used as an HMAC secret
Signature verified against the expected keyWas this minted by the issuer?Any caller can author their own claims
iss matches an issuer you trustWhich authority minted it?A valid token from an unrelated issuer is accepted
aud names this serviceWas it minted for us?A real token for a sibling service passes here
exp and nbf are in rangeIs it still current?Session length and revocation stop meaning anything
Claims re-checked against server stateIs role: admin still true?A stale privilege outlives the demotion that removed it

In practice the first five are configuration, not code. Every mature JWT library takes an allowed-algorithms list, an expected issuer, and an expected audience as explicit parameters, and most default at least one of them to "accept anything" so the quickstart works. The audit is short: find every call site and read what was passed, not what the function is named.

The sixth is the one no library can do for you. A claim is a snapshot of what was true at issue time, so a role baked in at login survives the demotion an hour later, cryptographically perfect and factually wrong. Privileged operations should read the authoritative record instead.

None of this is hard. It is six separate things, collapsed by habit into one sentence, "the token is valid," that never says which of the six it means.

Verified under which algorithm, against which key, issued by whom, for which audience, and still in date. Five answers. Most code confidently supplies one.

Related readingPart of our series on modern application security testing. Start with What You Should Know About Application Security Testing, then read Authentication Is Not Authorization on the question a valid session still leaves unanswered.

Frequently asked questions

What is the difference between decoding and validating a JWT?

Decoding splits the token and parses its claims. It requires no key and proves nothing. Validating verifies the signature against a key the server trusts, using an algorithm the server chose, then checks that the issuer, audience, and expiry match what this application expects. Both return the same-looking claims object, which is why the mistake is easy to make and hard to catch in review.

Is a JWT encrypted?

A standard signed JWT is not. Its header and payload are base64url-encoded, which is a transport encoding rather than a confidentiality control, so anyone holding the token can read every claim in it. Signing protects integrity, not secrecy. Never put anything in a JWT that you would not hand directly to the client, because you already have.

What is the "alg: none" attack?

JWT supports an unsecured mode where the algorithm header is none and the signature is empty. If a library takes its algorithm from the token rather than from configuration, an attacker can strip the signature, set alg to none, and have their claims accepted unverified. RFC 8725 makes algorithm verification its first best practice: the acceptable algorithms should be fixed by the server and its keys, never read from the token.

Why does the audience claim matter if the signature is valid?

Because a valid signature only proves who issued the token, not who it was for. When several services trust the same issuer, a token legitimately obtained for one can be replayed against another, and every cryptographic check will pass. RFC 8725 calls this cross-JWT confusion. Checking that aud names your specific service is what separates a token meant for you from a real token meant for someone else.

Can a JWT be revoked before it expires?

Not by the token itself. A signed JWT is valid until exp passes, which is the property that makes it stateless and also the reason logout, password reset, and role changes do not reach it. Teams handle this with short lifetimes plus refresh tokens, a server-side denylist checked on sensitive operations, or re-reading the authoritative record for privileged actions instead of trusting a role claim baked in at issue time.

Test what your application actually answers.

Start free, or book a demo to see NightVision derive your API inventory from source and test it fully authenticated against the endpoints your application really serves.