ToolzYard Blog

Developer guides and tutorials

JWT Security Guide

JWT Decode vs JWT Verify: What’s the Difference and Why It Matters

Published: March 11, 2026 • Updated: July 10, 2026 • By , Founder of ThreeWorks

Every JWT tutorial says "decode the token." Fewer explain that decoding and verifying are completely different operations — one needs no key and proves nothing, the other checks a cryptographic signature and a set of claims and is the only thing standing between your API and a forged admin token. Confusing the two is not a beginner-only mistake; it is behind some of the most cited authentication CVEs of the last decade. This guide draws the line precisely and walks through the two classic attacks that turn a sloppy verify step into account takeover.

Anatomy of a token

A JWT is three base64url segments joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiJ9 . 3Tc9x...signature
        header                              payload                           signature

The header names the signing algorithm (for example {"alg":"HS256","typ":"JWT"}). The payload carries claims. The signature is computed over the first two segments. The critical fact: base64url is encoding, not encryption. A standard JWS-signed JWT is signed, not encrypted — encryption is an entirely separate spec (JWE). That has one non-negotiable consequence.

Decoding is public: never put secrets in the payload

Decoding is just base64url-decoding two strings. No key is involved. Anyone who holds the token — a browser extension, a proxy log, an attacker who grabbed it from local storage — can read every claim in the payload without your secret and without your permission. Paste any token into the JWT Decoder and you will see the payload in cleartext instantly.

So the payload is the wrong place for anything sensitive: no passwords, no full credit-card numbers, no internal flags you would not hand to the user directly. Base64url is the same reversible encoding covered in the hashing vs encryption guide — it changes the representation of data, never its confidentiality. Assume the payload is world-readable, because it is.

Verifying checks the signature AND the claims

Verification is the step that produces trust. It recomputes the signature over header.payload using your secret (HS256) or the issuer's public key (RS256) and rejects the token if it does not match — and then it validates the claims that decoding happily ignores:

  • exp — expiry; reject if the token is past it
  • nbf — not-before; reject if the token is not yet valid
  • iss — issuer; must match the party you trust to mint tokens
  • aud — audience; the token must be intended for your service, not a sibling API that happens to share a signing key

A signature-valid token with the wrong aud is still a rejection. Skipping the audience check is how a token minted for a low-privilege service gets replayed against a high-privilege one. The signature underneath is typically an HMAC (HS256) or an RSA/ECDSA signature (RS256/ES256); we cover how HMAC keys a hash in the hashing vs encryption guide.

Decoding without verifying trusts attacker-controlled data. An attacker can flip "role":"user" to "role":"admin", push exp years into the future, or swap the sub to impersonate someone — and it all still decodes into valid JSON. Only the signature check catches it.

Attack 1: the alg: none bypass

The JWT spec defines an algorithm literally named none — a token with no signature at all, intended for cases where integrity is guaranteed by another layer. The problem: several popular libraries historically accepted a token whose header said "alg":"none" and treated it as valid with an empty signature. An attacker takes a real token, rewrites the payload, sets the header algorithm to none, strips the signature, and the naive verifier waves it through. The mitigation is blunt and mandatory: reject none on the server, and only accept the specific algorithm you expect.

Attack 2: RS256 → HS256 algorithm confusion

This one is subtle and worth internalizing. With RS256, tokens are signed with the issuer's RSA private key and your server verifies them with the corresponding public key — which, being public, is not a secret. With HS256, the same key both signs and verifies (symmetric HMAC).

Now the attack: the attacker changes the header from RS256 to HS256, then signs a forged token using your RSA public key as the HMAC secret. If your verification code reads the algorithm from the token and passes the same key parameter to a generic verify function, the library dutifully runs HMAC-SHA256 with the public key — the exact value the attacker just used — and the signatures match. The forgery is accepted because the public key was never meant to be secret, yet the code let the token dictate that it be used as one.

The fix is algorithm pinning: hard-code the expected algorithm server-side (e.g. algorithms: ["RS256"]) and never let the incoming token choose how it is verified. Treat the header's alg as an attacker-supplied hint, not an instruction.

Two bugs that pass every happy-path test

Comparing signatures with ==

Signature comparison must be constant-time. A byte-by-byte compare that returns early on the first mismatch leaks, through timing, how many leading bytes were correct — enough, in principle, to forge a signature one byte at a time. Reputable libraries use a constant-time comparison internally; if you ever hand-roll the check, use a constant-time equality function.

Putting milliseconds in exp

exp, nbf, and iat are NumericDate values — seconds since the Unix epoch, not milliseconds. In JavaScript, Date.now() returns milliseconds, so writing exp: Date.now() + 3600000 produces a timestamp roughly a thousand times too large — a token that "expires" tens of thousands of years from now and effectively never dies. The correct value is Math.floor(Date.now() / 1000) + 3600. This bug never shows up in testing because the token is always valid; it only surfaces as an auditor asking why sessions are immortal.

The revocation trade-off nobody mentions upfront

The selling point of a JWT is that it is stateless — the server verifies the signature and trusts the claims without a database lookup. The hidden cost is the mirror image: you cannot revoke a stateless JWT before it expires. If a token is stolen or a user is banned, a valid signature keeps working until exp passes. There is no "log out everywhere" for a pure JWT. Teams recover revocation with extra infrastructure — short-lived access tokens (minutes) paired with refresh tokens, or a server-side denylist of revoked token IDs — but each of those quietly reintroduces the state the JWT was supposed to eliminate. Choose the pattern deliberately; do not discover this the day you need to force-logout a compromised account.

Common JWT claims you will see

  • sub — subject, typically the user ID
  • iss — issuer that minted the token
  • aud — intended audience (validate this)
  • exp — expiry, in seconds since the epoch
  • iat — issued-at time
  • nbf — not-before time

Custom claims such as role, email, permissions, or tenant ID are common too — and every one of them is readable by whoever holds the token.

Best practices for working with JWTs

  • Decode for inspection and debugging only; make trust decisions on verification alone
  • Pin the accepted algorithm server-side; reject none and reject unexpected algorithms
  • Validate exp, nbf, iss, and aud, not just the signature
  • Store exp/nbf in seconds, never milliseconds
  • Keep secrets out of the payload — it is world-readable
  • Plan revocation up front: short expiry plus refresh tokens, or a denylist

Useful ToolzYard tools

Conclusion

Decoding a JWT is a public, keyless read of attacker-controllable JSON — useful for debugging, worthless as a trust signal. Verification is where security lives: check the signature, pin the algorithm so the token cannot downgrade itself to none or trick you into HMAC-ing with a public key, and validate exp, nbf, iss, and aud. Keep secrets out of the payload, count time in seconds, and decide your revocation story before you ship. The one-line takeaway: decoded data is readable, but only verified data should be trusted.

Frequently Asked Questions

Why can anyone decode a JWT without a key?

Because the header and payload are base64url-encoded, not encrypted. A standard JWT is signed, not encrypted (encryption is the separate JWE spec), so anyone holding the token can read every claim. Never place secrets in the payload.

What does verification check besides the signature?

A correct verify step also validates the claims: exp (not expired), nbf (already valid), iss (trusted issuer), and aud (intended for your service). A signature-valid token with the wrong audience should still be rejected.

What is the RS256-to-HS256 confusion attack?

The server verifies RS256 with a public key. An attacker switches the header to HS256 and signs the token using that public key as the HMAC secret. A library that takes the algorithm from the token and reuses the same key will accept the forgery. Fix it by pinning the expected algorithm server-side.

Why is my JWT never expiring?

exp is NumericDate — seconds since the Unix epoch, not milliseconds. Passing Date.now() (milliseconds) makes the expiry roughly 1000x too far in the future. Use Math.floor(Date.now() / 1000) plus your lifetime in seconds.

Can I revoke a JWT before it expires?

Not a purely stateless one — a valid signature is accepted until exp passes. To revoke early you need extra infrastructure: short-lived access tokens with refresh tokens, or a server-side denylist of revoked token IDs.