Xerobit
Encoding & Crypto

Decoding a JWT Is Not the Same as Verifying It

Every JWT bug in production reduces to the same mistake: trusting a decoded token without verifying its signature. The difference, the consequences, and how to do it right.

MA Mian Ali Khalid 9 min read
Free tool
JWT Decoder
Decode and inspect JSON Web Tokens. Local-only, tokens never leave your browser.
Open tool β†’
On this page βŒ„
  1. What's in a JWT, exactly
  2. Decoding β‰  verifying
  3. The production failure mode
  4. How to verify correctly
  5. When decoding-without-verifying is OK
  6. A simple mental model
  7. Tools for the job
  8. The one rule
  9. Further reading

A JWT looks like one string. It's actually three things welded together with periods. Knowing the difference between reading those three things and verifying them is the line between auth-as-intended and security-as-disaster.

This post is the difference, in plain terms, with the production consequences of getting it wrong.

What's in a JWT, exactly

A JWT (JSON Web Token, RFC 7519) is a string of three Base64URL-encoded sections separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTk5OTk5OTk5OX0.signature
└────── header β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ payload β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€ signature β”€β”˜
  • Header: a small JSON object with the signing algorithm. Decodes to something like {"alg":"HS256","typ":"JWT"}.
  • Payload (a.k.a. claims): JSON with whatever the issuer wants: user ID, expiry, audience, custom data.
  • Signature: cryptographic bytes computed from Base64URL(header) + "." + Base64URL(payload) using the algorithm and key specified in the header.

The first two parts are not encrypted. They're encoded. Anyone who has the JWT can read them, instantly, for free.

Try it now. Take any JWT and paste into the JWT Decoder. The header and payload appear immediately. No key required. No password. Nothing.

Decoding β‰  verifying

Decoding means: split on dots, Base64URL-decode each part, parse the JSON. Anyone can do this. It tells you what the token claims to be.

Verifying means: re-compute the signature from the header+payload using the issuer's key, and check that it matches the signature in the token. This requires the key. It tells you that the token was actually issued by who it says, and the contents haven't been changed.

The vital insight: decoded data is just a claim, not a fact.

A decoded JWT might say {"sub": "admin", "role": "superuser"}. Without verification, you have no idea if:

  • The issuer actually granted that role
  • Anyone could have hand-crafted this token and pasted in any claims they wanted
  • The token expired three years ago

A verified JWT, in contrast, proves the issuer signed exactly these claims at the time of issuance, and the signature hasn't been tampered with.

The production failure mode

Every "we got hacked through our JWT auth" story I've read traces back to one of these:

Failure mode 1: The decode-and-trust

// 🚫 Catastrophically wrong
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.role === 'admin') {
  // grant admin
}

Anyone can craft a token with {"role": "admin"} and base64-encode it. No signature is checked. The "auth" is theater.

This pattern shows up because:

  • Devs use jwt-decoder tools (like ours!) for debugging, then accidentally translate the same logic into server code.
  • Frameworks sometimes have a jwt.decode() method (no verify) and a jwt.verify() method (with key). People grab the convenient one.
  • The token "works": it round-trips, the right fields appear, so it ships.

Failure mode 2: The "alg: none" attack

// 🚫 Naive verifier
const verified = jwt.verify(token, key, { algorithms: undefined });

Some JWT libraries default to allowing the special alg: "none" algorithm, which means no signature is required. An attacker crafts a JWT with {"alg":"none"} and an empty signature, and the verifier accepts it.

The fix: always specify allowed algorithms explicitly:

// βœ… Correct
const verified = jwt.verify(token, key, { algorithms: ['HS256'] });

Most modern libraries (jsonwebtoken 8+, jose) reject alg: none by default, but check yours.

Failure mode 3: HMAC vs RSA key confusion

If your verifier accepts both symmetric (HS*) and asymmetric (RS*, ES*) algorithms with the same key lookup, an attacker can:

  1. Get your RSA public key (it's public: they're in your .well-known/jwks.json).
  2. Craft a JWT with {"alg":"HS256"} and sign it with your public key as the HMAC secret.
  3. Your verifier looks up "the key for HS256": gets the public key, uses it as HMAC. Signature checks out. Token accepted.

The fix: lock the algorithm per issuer. If your server expects RS256, only accept RS256.

Failure mode 4: Expiry not checked

// 🚫 Verifier ignores exp claim
jwt.verify(token, key, { algorithms: ['HS256'] });
// proceed without checking payload.exp

Some libraries verify the signature but don't enforce the exp (expiration) claim unless you ask. A token signed two years ago is "valid" forever.

The fix: most libraries have ignoreExpiration: false as default but verify yours. Always check exp. Most also support clockTolerance for small clock skew between issuer and verifier, keep this small (30-60 seconds).

Failure mode 5: Trusting the issuer URL from the token

JWS supports headers like jku (JWK Set URL) or x5u (X.509 URL) that tell the verifier where to fetch the key from. If your verifier blindly fetches whatever URL the token says, an attacker controls the URL β†’ controls the key β†’ controls the signature.

The fix: never trust jku or x5u from the token. Hardcode (or strict-allowlist) which JWKS endpoint you fetch keys from.

How to verify correctly

In Node.js with jsonwebtoken:

import jwt from 'jsonwebtoken';

try {
  const claims = jwt.verify(token, publicKey, {
    algorithms: ['RS256'],          // explicit, no fallback
    issuer: 'https://issuer.example.com',  // verify iss claim
    audience: 'my-api',              // verify aud claim
    clockTolerance: 30,              // 30 sec clock skew
  });
  // claims is now trustworthy
} catch (err) {
  // signature invalid, expired, wrong issuer, wrong audience, etc.
}

Note what verification also does in modern libraries:

  • Validates the algorithm matches expectations
  • Checks exp (expiration) and nbf (not-before)
  • Validates iss (issuer) and aud (audience) if you supply expected values
  • Computes signature with the correct algorithm

In Python with pyjwt:

import jwt

claims = jwt.decode(
    token,
    public_key,
    algorithms=['RS256'],
    issuer='https://issuer.example.com',
    audience='my-api',
)

(Python's decode() with a key actually verifies. The verify=False parameter disables it. Don't pass verify=False outside of debugging.)

When decoding-without-verifying is OK

There are exactly two cases where decoding without verifying makes sense:

  1. You're a debugging tool. Xerobit's JWT Decoder shows you what's in a token without needing the signing key. You should never paste your signing key into a third-party web tool. The trade-off: you see the claims but can't prove they're authentic: and the tool labels this clearly.

  2. You're inspecting your own freshly-issued token. When debugging your own auth flow, you can decode tokens you just issued because you trust the issuance step. This is for development; never extends to production.

In every other case, decode without verify = no security. Period.

A simple mental model

A JWT is a paper letter. Decoding is reading the letter. Verifying is checking the wax seal.

You can read any letter that lands in your hands. The text is plain and visible. But that text is just a claim, anyone could have written it.

The seal proves who wrote it. To verify the seal, you need the writer's seal-stamp (the signing key). Without that, the seal is just decoration.

A well-crafted forgery looks identical to a real letter. The only difference is the seal. Skipping the verify step is reading a forged letter and acting on it.

Tools for the job

  • JWT Decoder: for debugging your own tokens. Decodes; never verifies (intentionally).
  • Server-side: jsonwebtoken, jose, or passport-jwt (Node); pyjwt (Python); jose-jwt (.NET); JJWT (Java).
  • For machine-to-machine OAuth flows where the server needs to fetch keys, use a JWKS-aware library that caches and respects kid headers correctly.

The one rule

Never trust the contents of a JWT until you've verified the signature with the issuer's key.

That's it. Internalize that, and 95% of JWT security problems disappear.

Further reading

Keep reading

Base64: How It Actually Works Under the Hood

Base64 is everywhere, in JWTs, data URLs, email attachments. This is the byte-level walkthrough of what it does, why it grows files by 33%, and the URL-safe variant.

JWT Security Checklist for 2026

Twelve checks every JWT implementation should pass before shipping. The actual checklist used by security teams, with the failure mode each prevents.

MD5 Is Dead. Use These Instead.

MD5 was broken in 2004 and is trivially cracked for passwords. Here's what to use instead for checksums, passwords, and message authentication, and why it matters.

JWT Authentication Flow: Login, Token Storage, Refresh Tokens

JWT authentication involves issuing tokens on login, sending them with requests, and refreshing before expiry. Covers the full flow in Node.js, secure storage (httpOnly cookies...

JWT Best Practices: Secure JSON Web Token Implementation

JWT implementation mistakes lead to authentication bypasses and security vulnerabilities. Here's how to sign, validate, and store JWTs correctly, covering algorithm confusion...

JWT Standard Claims: iss, sub, aud, exp, nbf, iat, jti Explained

JWT registered claims define standard metadata for tokens: issuer (iss), subject (sub), audience (aud), expiration (exp), not-before (nbf), issued-at (iat), and JWT ID (jti)....

JWT Refresh Token: Implement Sliding Sessions with Refresh Tokens

Refresh tokens extend JWT sessions without requiring re-login. Learn how to implement a refresh token flow with short-lived access tokens, secure httpOnly refresh token...

JWT Security Best Practices: Common Vulnerabilities and How to Avoid Them

JWT vulnerabilities include algorithm confusion attacks, weak secrets, missing expiry, and storing tokens in localStorage. Learn how to implement JWTs securely: algorithm...

JWT Token Structure: Header, Payload, and Signature Explained

A JWT has three Base64URL-encoded parts: header, payload, and signature, separated by dots. Here's how each part works, what claims mean, and how to read and decode a JWT token.

JWT vs Session Authentication: When to Use Each

JWT and session authentication have different tradeoffs. Sessions store state server-side; JWTs are stateless. Learn when stateless JWTs are genuinely useful, when sessions are...

Related tools

Written by Mian Ali Khalid. More guides in Encoding & Crypto.