JWT, deeply: what every developer gets wrong about JSON Web Tokens
A complete walkthrough of how JWTs actually work, what HS256 vs RS256 means in practice, the alg=none attack, when to use a JWT vs a session cookie, and the most common mistakes in production deployments.
JSON Web Tokens are everywhere — every modern authentication tutorial, every OAuth provider, every "stateless auth" diagram. They're also one of the most misunderstood pieces of common web infrastructure. This post is a thorough walkthrough of how JWTs actually work, the design decisions baked into them, what their failure modes look like in production, and the specific things developers get wrong about them.
If you've ever copy-pasted a JWT implementation from a blog post and wondered whether it was actually secure, this is for you.
What a JWT actually is
A JWT is a string with three parts separated by dots: header.payload.signature. The header and payload are base64url-encoded JSON; the signature is a cryptographic signature over header.payload that proves the token hasn't been tampered with. That's the whole format.
A real JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzE1NjAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decode the three parts and you get:
{
"alg": "HS256",
"typ": "JWT"
}.{
"sub": "1234567890",
"name": "Alice",
"iat": 1715600000
}.<binary signature>
The header says "HS256" — HMAC with SHA-256. The payload contains "claims" — the standard ones from RFC 7519 (sub, iat, exp, iss, aud, jti, nbf) plus anything else the issuer wants to include. The signature lets the receiver verify the token came from someone who knows the secret key.
The first thing most people miss: JWTs are NOT encrypted
Base64url encoding is not encryption. Anyone with the token can decode the header and payload — paste it into our JWT decoder and you'll see the contents instantly. The signature prevents tampering, not reading.
This means: don't put sensitive data in a JWT. Email addresses are fine; password hashes, credit card numbers, social security numbers, private user notes — anything you wouldn't want a curious user with their own valid token to read about themselves — should not be in the payload. JWTs are designed for assertions ("this user has ID 1234 and the role 'admin'"), not for transporting confidential data.
If you genuinely need to encrypt the payload, the spec has a sibling called JWE (JSON Web Encryption, RFC 7516). JWE uses the same dot-separated format with five parts instead of three and the payload is actually encrypted. Almost nobody uses JWE in practice — it's much more complex, support is patchier, and the cases where you'd need both authentication AND encryption usually call for something other than a self-contained token.
HS256 vs RS256: shared secret vs public-key signing
The alg field in the header tells the verifier how to check the signature. The two algorithms you'll see in practice are:
HS256 uses HMAC with SHA-256. Both the issuer (the side creating the token) and the verifier (the side checking it) need the same secret key. It's fast and symmetric — anyone who can verify the token can also forge it. Use this when the same service that issues tokens also verifies them.
RS256 uses RSA with SHA-256. The issuer signs with a private key; verifiers check with the corresponding public key. Asymmetric — verifiers can't forge tokens, only verify them. Use this when token issuance and verification happen in different services. Most OAuth providers (Google, Auth0, Okta) issue RS256 tokens because they want a thousand different applications to verify their tokens without ever knowing the secret.
There's also ES256 (ECDSA with P-256 curve, more modern, smaller signatures) and a handful of others, but HS256 and RS256 cover 95% of real deployments.
The alg=none attack: still alive in 2026
One of the worst design decisions in the JWT spec was allowing "alg": "none" — a signature-free mode meant for development and testing. A token with alg=none has an empty third part: header.payload.. The verifier is supposed to accept it as unsigned.
You can see where this is going: an attacker takes a real token, swaps the header to {"alg": "none"}, edits the payload to claim they're an admin, removes the signature, and sends it. A naive verification library will say "the signature is valid because the algorithm is none". You're now authenticated as an admin.
This was the headline JWT vulnerability of 2015 and continued landing in CVEs through 2020. Every JWT library now defaults to rejecting alg=none — but you should explicitly verify your library does. The fix: when verifying, pass the expected algorithm and reject anything that doesn't match.
// Wrong (trusts the header):
jwt.verify(token, secret);
// Right (forces the expected algorithm):
jwt.verify(token, secret, { algorithms: ['HS256'] });
A related attack: swap an RS256 token's header to HS256, then sign it with the RSA public key as if it were the HMAC secret. The verifier, expecting HS256, validates against the public key — which is what the issuer signed with originally. You've forged a token using only publicly available material. Defense: same as above — always specify the expected algorithm explicitly.
Standard claims and what they actually mean
RFC 7519 defines seven standard claims with three-letter abbreviations. The names are unfortunate but you can't change them now.
- iss (issuer) — who created this token. A URL or identifier. Receivers should verify this matches a configured value before trusting the token.
- sub (subject) — the user or principal the token is about. Usually a user ID. Note: it's a string, not an integer — leading-zero "007" and "7" are different subjects.
- aud (audience) — who this token is for. Receivers should verify it's the intended audience. Without this check, a token meant for one service could be replayed against another.
- exp (expiration) — Unix timestamp in seconds. Verifiers MUST reject tokens past their expiration. Always include this.
- nbf (not before) — Unix timestamp; the token isn't valid until this time. Rare in practice.
- iat (issued at) — Unix timestamp when the token was created. Informational; not used for validation.
- jti (JWT ID) — unique identifier for this specific token. Used for revocation lists ("this token has been logged out").
You can put any other fields you want in the payload. Common additions: name, email, roles, permissions, tenant_id. These are called "custom" or "private" claims; the spec recommends using URL-based names to avoid conflicts but nobody does.
The "logout" problem
The fundamental tradeoff of JWTs: they're stateless. The whole point is that the verifier doesn't need to call a database — the token carries everything needed for verification. But this means there's no central place to mark a token "logged out". If a user logs out, the token they were holding is technically still valid until exp.
This becomes painful when: an admin gets compromised, you need to revoke their session immediately. With a session-cookie system, you delete the session row in the database. With JWTs, the token in the attacker's hands is good until expiration regardless of what you do server-side.
The common fixes, in increasing order of complexity:
- Short expirations. 15 minutes is typical. Pair with refresh tokens (a longer-lived token that's exchanged for new access tokens). Logout revokes the refresh token; the access token expires soon enough that it doesn't matter much.
- Token revocation list. Keep a server-side list of
jtivalues that have been revoked. Verifiers check the list. You've added back some of the state JWTs were supposed to eliminate, but it's a small list (only revoked active tokens). - Rotation on every request. Each request issues a new token with a new
jti; the previousjtiis added to a revoked list. Effectively makes JWTs short-lived to one request.
If you find yourself building all three layers, ask whether you should have just used a session cookie.
When to use JWTs and when not to
JWTs make sense when:
- You have multiple services that need to verify the same identity without coordinating. Microservices behind an API gateway is the canonical case.
- You can't easily share session state across services, or sharing introduces an unacceptable performance/availability dependency.
- The token transports trustworthy assertions made by a third party. OAuth providers issuing tokens about a user's permissions is the prototypical case.
JWTs are worse than session cookies when:
- You have one app and one database. A session cookie pointing at a server-side session record is simpler, supports immediate logout, and stores no PII in the client. Most web apps don't need JWTs.
- You need fine-grained authorization that changes frequently. Revoking a user's "admin" role with a JWT is the logout problem all over again. Server-side authorization lookups are immediate.
- You're putting JWTs in localStorage. Cookies with HttpOnly and SameSite=strict are more secure than localStorage because they're not accessible to JavaScript and therefore not exfiltratable by XSS.
The single biggest JWT misuse pattern: a single-domain web app that stores a JWT in localStorage, sends it on every fetch with an Authorization header, and treats this as "stateless authentication". It's stateless in the wrong way — you lose the security of HttpOnly cookies, you don't get the cross-service benefit (there's only one service), and you've added complexity for nothing.
Verifying a JWT correctly
For any JWT verification in production code, the checklist:
- Specify the algorithm explicitly. Don't trust
header.alg— pass{ algorithms: ['RS256'] }(or whatever you expect) to the verify call. - Verify the signature. Use a battle-tested library; don't implement HMAC or RSA verification yourself.
- Check exp. Most libraries do this automatically; confirm yours does. Reject if missing or past.
- Check iss. Reject if the issuer is not the one you trust.
- Check aud. Reject if this token wasn't intended for your service.
- Don't fetch the verification key from the token itself. Some implementations have a
jku(key URL) orjwk(embedded key) header. Trusting these means attackers can sign with their own key and tell you where to find it for verification. Always use a key from a server-configured source.
If your code follows all six points, you have a reasonable JWT setup. Most production code skips at least two.
JWKS: how OAuth providers distribute keys
When the issuer signs with RS256 or ES256, verifiers need the public key. Hardcoding it works but breaks when the issuer rotates keys. The solution is JWKS (JSON Web Key Set, RFC 7517): the issuer publishes their current set of public keys at a well-known URL, usually https://example.com/.well-known/jwks.json.
The JWT's header contains a kid (key ID) field; the verifier fetches the JWKS, finds the key with that ID, and uses it. When the issuer rotates keys, they publish the new one in JWKS before signing tokens with it; verifiers automatically pick it up.
Production gotchas: cache JWKS responses (typical TTL: 1 hour) — fetching on every verification is a latency disaster. Handle the case where kid isn't in your cached set gracefully (refresh the cache, retry). Don't ever follow the jku header to a URL you don't trust — that's the attack mentioned above.
Real-world recommendations
If you're building authentication from scratch in 2026 and you don't have a specific reason to use JWTs, use session cookies with a session-store backend. Simpler, more secure by default, easier to debug, and immediate logout works.
If you're integrating with OAuth providers (Google sign-in, Auth0, Okta), you'll be verifying their JWTs. Use a maintained library (jsonwebtoken in Node, python-jose in Python, jjwt in Java). Configure JWKS, specify expected algorithms and issuer, and you're done.
If you're issuing JWTs yourself for a multi-service system, use RS256, keep expirations short (15 minutes for access tokens, 7 days for refresh tokens), implement a revocation list keyed on jti, and log every token issuance for audit purposes.
Test your tokens with the JWT decoder as you build — it shows the header, payload, and verifies HS256 signatures against a secret you provide. The secret stays in your browser; nothing is logged.