APIs & Auth

JWTs Explained: How to Read, Verify, and Avoid the Common Mistakes

JSON Web Tokens show up everywhere a service needs to prove who a caller is: OAuth access tokens, OpenID Connect ID tokens, session cookies, and service-to-service calls. They're also widely misunderstood — most notably, many developers assume a JWT is encrypted. A standard signed JWT is not. Anyone who holds one can read everything inside it.

This guide covers the anatomy of a JWT, the critical difference between decoding and verifying, and the mistakes that appear again and again in real systems. The underlying standard is RFC 7519.

Anatomy: three Base64URL parts

A JWT is three segments separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c3JfOGYyYSIsInJvbGUiOiJlZGl0b3IiLCJleHAiOjE3OTAwMDAwMDB9
.3q6c4Hk3v...signature...

(Line breaks added for readability — a real token is one continuous string.)

  1. Header — JSON describing the token, most importantly the signing algorithm: {"alg":"HS256","typ":"JWT"}.
  2. Payload — JSON containing the claims: {"sub":"usr_8f2a","role":"editor","exp":1790000000}.
  3. Signature — computed over the first two segments using the algorithm in the header and a key.

Each segment is Base64URL-encoded: standard Base64 with + and / replaced by - and _, and the trailing = padding removed so the token is safe to use in URLs and headers. That's why a regular Base64 decoder sometimes chokes on a JWT segment — restore the characters and padding first, or use a Base64URL-aware decoder.

Decoding is not verifying

This is the single most important idea in this guide.

  • Decoding means Base64URL-decoding the header and payload so you can read them. It needs no key. Anyone can do it.
  • Verifying means recomputing the signature with the expected key and algorithm and confirming it matches, then checking the claims (expiry, issuer, audience). Only verification tells you the token is genuine and unmodified.

Decoding is perfectly fine for debugging — "why does this request get a 403?" is often answered by reading the token's scope or aud claim. But server-side code must never make an authorization decision based on a decoded-but-unverified token. Many libraries expose both a decode() and a verify() function; reaching for the wrong one is a surprisingly common bug.

The payload is readable by anyone

Because the payload is only encoded, not encrypted, never put anything in it that the token holder shouldn't see: passwords, API secrets, internal IDs you consider sensitive, or personal data beyond what's necessary. Tokens end up in browser storage, logs, and error reports.

If you genuinely need confidentiality, that's a different standard — JWE (JSON Web Encryption), which uses five segments instead of three.

HS256 vs RS256 (and ES256)

HS256RS256 / ES256
TypeSymmetric (HMAC with SHA-256)Asymmetric (RSA or ECDSA signature)
KeysOne shared secret signs and verifiesPrivate key signs; public key verifies
Best forA single service that issues and checks its own tokensMany services verifying tokens from one issuer
Main riskEvery verifier can also forge tokensKey distribution and rotation (usually via a JWKS endpoint)

With HS256, any service that can verify a token holds the secret needed to mint new ones — fine inside one application, risky once the secret is copied across several. With RS256 or ES256, the identity provider keeps the private key, and every other service only needs the public key, typically fetched from a /.well-known/jwks.json endpoint.

For HS256, the secret must be long and random — at least 256 bits (32 random bytes). Short human-chosen secrets like "secret" or a product name can be brute-forced offline from a single captured token, because the attacker can test guesses without ever contacting your server.

Registered claims worth checking

ClaimMeaningWhat to check
expExpiration timeReject if now ≥ exp. Allow a small clock-skew leeway (e.g. 30–60 seconds).
nbfNot beforeReject if now < nbf.
iatIssued atUseful for revoking everything issued before a known incident.
issIssuerMust exactly match the issuer you trust.
audAudienceMust include your service. Prevents a token meant for service A being replayed against service B.
subSubjectThe user or client the token represents.

Time claims are NumericDate values: seconds since the Unix epoch, not milliseconds. Writing Date.now() straight into exp produces a token that expires tens of thousands of years from now — a classic bug. Use Math.floor(Date.now() / 1000).

Mistakes that keep showing up

Trusting the alg header

The header says which algorithm was used, but an attacker controls the header. Historically, some libraries honored "alg":"none" (no signature at all) or could be tricked into verifying an RS256 token as HS256 using the public key as the HMAC secret. The fix is simple: always tell your verification function exactly which algorithm or algorithms you accept, and never derive that from the token itself.

Skipping aud and iss checks

Signature verification proves a trusted issuer signed the token. It doesn't prove the token was meant for you. If several services trust the same identity provider, only the audience check stops tokens from being reused across them.

Long-lived tokens with no revocation story

A JWT stays valid until it expires, even if the user logs out or their account is disabled. Keep access tokens short-lived — minutes, not days — and use refresh tokens, which your server can revoke, to obtain new ones.

Treating JWTs as a session store

Stuffing permissions, preferences, and profile data into the payload makes every request larger and means changes don't take effect until the token is reissued. Keep claims minimal and look up the rest server-side.

A safe debugging workflow

  1. Decode the header and payload to see what the token actually claims.
  2. Convert exp, nbf, and iat from Unix seconds into readable dates — expired tokens are the most common cause of a sudden 401.
  3. Compare iss and aud against what the receiving service expects.
  4. Only then look at signature and key problems.

Be careful where you paste production tokens. A token is a live credential until it expires: prefer tools that decode entirely in your browser rather than sending the token to a server, and never paste one into a shared chat or ticket.

Related: if you're getting a 401 or 403 while debugging auth, our guide to HTTP status codes explains the difference between the two.

Try this in JSON Editor Pro

Format, validate, compare, and explore JSON — entirely in your browser.

Keep reading

More APIs & Auth guides