JWT Explained
A JWT is three Base64 segments and a signature. Understanding what the signature does — and does not — prevent is the whole game.
JSON Web Tokens are everywhere — API authentication, single sign-on, service-to-service calls — and they are widely misunderstood. The misunderstanding is predictable, because the thing that looks most important (the signature) protects something different from what most people assume, and the thing that looks like decoration (the Base64 encoding) is what actually determines what you are allowed to put in a token.
Anatomy of a JWT
A JWT is three Base64URL-encoded segments separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjM0NTY3ODkwIn0 . SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└──────────── 1. header ────────────┘ └────── 2. payload ──────┘ └────────── 3. signature ──────────┘
1. Header
{
"alg": "HS256",
"typ": "JWT"
}
The signing algorithm and token type. Common algorithms: HS256 (HMAC with
SHA-256, symmetric), RS256 (RSASSA-PKCS1-v1_5 with SHA-256, asymmetric),
ES256 (ECDSA with P-256, asymmetric).
2. Payload
{
"sub": "user_1042",
"name": "Ada Lovelace",
"role": "admin",
"iss": "https://auth.example.com",
"aud": "api.example.com",
"iat": 1758000000,
"exp": 1758003600
}
The claims. Registered claims (iss, sub, aud,
exp, nbf, iat, jti) are defined by
RFC 7519; everything else is application-defined.
3. Signature
Computed over the first two segments:
HS256: HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
RS256: RSA-SHA256( base64url(header) + "." + base64url(payload), private_key )
The signature covers the exact bytes of the first two segments. Change one character in the payload and the signature no longer validates.
Encoded is not encrypted
This is the most consequential fact about JWTs and the source of most incidents. The header and payload are Base64URL-encoded, not encrypted. Anyone who has the token can read every claim in it in about a second:
echo 'eyJzdWIiOiIxMjM0NTY3ODkwIn0' | base64 -d
# {"sub":"1234567890"}
There is no key involved. No brute force. It is a decoding, not a decryption.
What the signature provides is integrity and authenticity: proof that the token was issued by someone holding the key, and that it has not been modified since. It provides no confidentiality whatsoever.
The practical rules that follow:
- Never put a secret in a JWT payload. No passwords, no API keys, no tokens, no session secrets.
- Be careful with personal data. A JWT travels through URLs, logs, browser storage and error reports. Putting PII in one has privacy implications beyond the security question.
- If you genuinely need confidentiality, use JWE (JSON Web Encryption) — a separate specification that actually encrypts the payload. It is far less commonly used, and in most systems the right answer is simply not to put anything sensitive in the token.
Verification versus decoding
Decoding is string manipulation anyone can perform. Verification requires the key, and it is the step that provides security. Conflating them is the root of several CVEs.
The server-side rule is absolute: verify the signature before reading a single
claim. An unverified token is untrusted user input, exactly like a form field.
Reading claims from an unverified token and acting on them is equivalent to trusting a
client-supplied is_admin flag.
In practice, correct verification means:
// Node (jsonwebtoken) — pass the algorithm explicitly
jwt.verify(token, publicKey, { algorithms: ["RS256"], audience: "api.example.com",
issuer: "https://auth.example.com" });
# Python (PyJWT)
jwt.decode(token, public_key, algorithms=["RS256"],
audience="api.example.com", issuer="https://auth.example.com")
Note the algorithms parameter. That single argument is what prevents the
algorithm confusion attack described below. Most libraries also verify
exp and nbf automatically — but verify that yours does.
The attacks JWTs are actually vulnerable to
Algorithm confusion (RS256 → HS256)
Suppose a server signs with RS256: it holds a private key, and verifiers hold the
matching public key. If the verification code trusts the token's
alg header instead of pinning the expected algorithm, an attacker can:
- Take the public key — which is public, by definition.
- Create a token with
alg: HS256and arbitrary claims. - Sign it with HMAC using the public key as the HMAC secret.
- Send it to the server.
A library that trusts the header will HMAC-verify using the public key it already has, succeed, and accept the forged token. Fix: always pass the expected algorithm explicitly to your verification call.
alg: none
Some libraries historically accepted tokens declaring alg: none — unsigned
— and treated them as valid. That means anyone can issue arbitrary claims. Modern
libraries reject none by default, but older code and some configurations do
not. Fix: pin the algorithm; never allow none.
Weak HMAC secrets
HS256 uses a shared secret. If it is short or guessable, an attacker with one valid token can brute-force it offline — no rate limit, no detection, as fast as their hardware allows. Once found, they can mint any token. Fix: use a random secret of at least 32 bytes, and prefer asymmetric algorithms when the verifier does not need to sign.
Missing or ignored exp
A token with no exp is valid forever. If your code does not require
exp, a single leaked token grants permanent access. Fix:
reject tokens without exp unless you have a specific reason not to.
Trusting client-supplied roles
A role claim is data, not authorisation. If a user's role is downgraded in
your database but their token still says admin, and your API trusts the
claim, they keep admin access until the token expires. Fix: make
authorisation decisions against server-side state, or use short token lifetimes so the
window is small.
Token leakage through logs and referrers
JWTs in URLs end up in access logs, browser history, and Referer headers
sent to third parties. Fix: send tokens in the
Authorization header, never in a query string.
Claims you must check
| Claim | Meaning | What to do |
|---|---|---|
exp | Expiration time | Reject if past. Reject if missing. |
nbf | Not valid before | Reject if in the future. |
iat | Issued at | Useful for age checks and debugging. |
iss | Issuer | Reject if not your trusted issuer. |
aud | Audience | Reject if not intended for your service. |
sub | Subject | Identify the user; do not trust it as authorisation. |
jti | Token ID | Use for revocation lists and replay detection. |
The aud check is skipped most often and matters more than it looks.
Without it, a token issued by the same identity provider for a different service in your
organisation will authenticate successfully against yours.
On clock skew: exp is an absolute Unix timestamp, so if the issuing
machine's clock is even a minute ahead of the verifier's, freshly issued tokens are
rejected. Most libraries accept a small leeway — 30 to 60 seconds is typical and
reasonable. Ensure your hosts run NTP.
Expiry is not revocation
A JWT is self-contained: a verifier needs no server-side state to validate it. That
statelessness is the main reason to use JWTs, and it is also precisely why revocation is
hard. Once issued and signed, a token remains valid until exp. Logging out
does not invalidate it. Deleting it from the browser does not invalidate it — the token
is still out there, and anyone who copied it can still use it.
The standard mitigation is a two-token design:
- Access token — short-lived (5–15 minutes), sent with every request, verified statelessly. Because its lifetime is short, the damage window after leakage is bounded.
- Refresh token — long-lived, stored server-side, revocable. Used only to mint new access tokens. Because the server looks it up, genuine logout is possible: delete the refresh token and access stops within minutes.
If you need immediate revocation, add a denylist keyed on jti, checked on
every request. That costs you some statelessness — the check is a lookup — but it is the
only way to get real-time invalidation with self-contained tokens.
Where to store a token
Both common browser options have real trade-offs, and anyone telling you one is obviously correct is oversimplifying.
localStorage
Simple, survives reloads, and works cleanly with single-page apps. The problem: any
Cross-Site Scripting vulnerability on your site reads it. Given how easily XSS arrives
through a transitive dependency or an unsanitised render, this is a genuine risk. A
long-lived token in localStorage is the worst combination.
HttpOnly; Secure; SameSite cookie
Inaccessible to JavaScript, so XSS cannot exfiltrate it. The cost: you must handle
CSRF, because the browser attaches cookies automatically. SameSite=Strict or
SameSite=Lax plus a CSRF token for state-changing requests covers this.
The pattern most teams settle on
Keep the access token in memory (a JavaScript variable — lost on reload, which is fine because the refresh token restores it), and keep the refresh token in an HttpOnly cookie. XSS cannot read the refresh token, and the access token's short lifetime bounds the damage if it is captured.
What you should never do: put a long-lived JWT in localStorage.
JWT versus sessions
The trade-off is genuine and the popular "just use JWTs" advice is often wrong.
| Sessions | JWTs | |
|---|---|---|
| Server state | Yes — a store lookup | No — stateless verification |
| Revocation | Immediate | Hard; needs short expiry + refresh |
| Scale-out | Needs shared session store | Trivially horizontal |
| Per-request cost | Store lookup | Cryptographic verification |
| Payload size | Tiny ID | Larger — sent every request |
| Cross-service | Needs shared store | Natural fit |
Choose sessions for a conventional web application with a single backend. You get instant revocation, smaller requests, and simpler security reasoning. The "JWTs scale better" argument is weak here — a Redis session store scales fine.
Choose JWTs when you genuinely need stateless verification across services, when the token must carry claims to a party that cannot look them up, or for short-lived single-purpose tokens — password resets, email verification, a signed download link.
The common mistake is adopting JWTs for a single-server web app because they are modern, then spending months working around the revocation problem you introduced.
To inspect a token's contents while developing, use our JWT Decoder — it runs entirely in your browser and deliberately does not verify signatures, since that would require your key.
Frequently asked questions
No. It is Base64URL-encoded, which anyone can reverse in one command. The signature prevents tampering, not reading. Never put secrets in a payload; use JWE if you genuinely need confidentiality.
Access tokens typically 5–15 minutes. Short lifetimes are the main mitigation for the fact that JWTs cannot be revoked. Use a longer-lived refresh token stored server-side for continuity.
Not the token itself — it remains valid until it expires. You can revoke the refresh token that mints new ones, or maintain a denylist keyed on the jti claim and check it on every request.
HS256 is simpler but requires every verifier to hold the shared secret, which means anyone who can verify can also forge. RS256 keeps signing capability private and lets verifiers hold only a public key — generally the better choice across service boundaries.
It works but exposes the token to any XSS on your site. A safer pattern is an access token in memory plus a refresh token in an HttpOnly cookie. Never store a long-lived JWT in localStorage.
Often not. For a conventional single-backend application, sessions give you instant revocation and simpler reasoning. JWTs earn their complexity with cross-service stateless verification or short-lived single-purpose tokens.
Related guides
How AI Token Counting Works
A token is not a word, and the difference between the two is where most LLM cost estimates go wrong. Here is how tokenizers actually split your text.
LLM API Cost Compared
Published per-million-token prices are only half the equation. Tokenizer differences, caching, batching and reasoning tokens change the real number.
Prompt Engineering for Developers
Prompt engineering is mostly specification writing. Here is a structure that produces reliable output, and the failure patterns that break it.