JWT, Properly Explained
JWT was published as RFC 7519 in May 2015, building on the JOSE family of specs (JWS for signing, JWE for encryption, JWA for algorithm names, JWK for keys). It was designed to be the smallest possible self-contained, signed bearer token: a JSON object, signed, then Base64url-encoded into a URL-safe string. The design is two screens of spec text. The footguns have filled an entire decade of conference talks.
A JWT is not an authentication protocol. A JWT is not a session. A JWT is not magic. A JWT is a string format: three Base64url-encoded JSON blobs joined with dots. That's the whole format. Everything else β refresh tokens, OAuth flows, OpenID Connect, signing rotations β is built on top by other specs and conventions.
This is worth restating up front because most JWT bugs in the wild come from teams treating the format as a security mechanism instead of as a serialization format that carries security claims.
What's in a JWT
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzM1Njg5NjAwfQ.signaturesignature
Three parts, separated by .:
- Header β
{"alg":"HS256","typ":"JWT"}. The signing/encryption algorithm and a token type. - Payload β the claims. Usually
{"sub":"1234","exp":1735689600,...}. - Signature β the cryptographic signature over
header.payload, encoded.
Each part is Base64url encoded (RFC 4648 Β§5: - and _ instead of + and /, no padding). Decoding is trivial; inspecting one in jq is one shell command. That is the point of the format: any participant can read the contents without contacting the issuer.
The signature is what makes the token trustworthy. Without verifying the signature, the contents of a JWT are user-controlled input, period.
What kind of token this is
JWT (RFC 7519) is the claims format. It almost never travels alone:
- JWS (RFC 7515): the signature wrapping. JWS-signed JWTs are what almost everyone means when they say "JWT." Signed but not encrypted; payload is readable to anyone with the token.
- JWE (RFC 7516): the encryption wrapping. JWE-encrypted JWTs hide their payload from clients. Five parts instead of three. Used much less often.
- JWK (RFC 7517): the JSON format for representing keys.
- JWA (RFC 7518): the registry of algorithm names (
HS256,RS256,ES256, etc.).
In practice: when a backend API signs identity tokens for clients to verify, you're using JWT-as-JWS. When the issuer wants the token to be opaque to the client, you're using JWT-as-JWE β or, more commonly, an opaque token (a random string indexed in a database). The latter is often the right choice; more on that below.
The "alg: none" attack
In 2015, security researcher Tim McLean published "Critical vulnerabilities in JSON Web Token libraries." The headline finding: the JWT spec allowed an alg: none value, meaning "this token is not signed." Many libraries, when handed a token with alg: none, would accept it as valid without checking a signature.
header: {"alg":"none","typ":"JWT"}
payload: {"sub":"admin","exp":99999999999}
signature: (empty)
A JWT with the right shape, an admin claim, and no signature. Buggy libraries said "yep, valid." The fix was straightforward β require an explicit allowlist of expected algorithms when verifying β but it took years to clean up. New libraries still occasionally regress this, and alg: none should never be in your verification allowlist for a production token.
A second class of attack from the same era: algorithm confusion. If a library accepts both HMAC (HS256) and RSA (RS256) signatures, and resolves which to use based on the token's own alg header, an attacker who knows the server's public RSA key (which is public, by definition) can craft a token signed with HS256 using the public key as the HMAC secret. The library, seeing alg: HS256, validates the HMAC against the same public key β and accepts the forged token.
Defense, again: don't let the token tell the library which algorithm to use. The verifier must specify the expected algorithm and key out of band.
What lives in the payload
The spec defines a small set of registered claim names; everything else is application-defined. Registered claims worth knowing:
iss(issuer) β who minted the token.sub(subject) β who the token is about (typically a user ID).aud(audience) β who is the token for. A token issued forservice-Ashould be rejected byservice-B.exp(expiration) β UNIX timestamp after which the token is invalid.nbf(not before) β UNIX timestamp before which the token is invalid.iat(issued at) β UNIX timestamp when the token was minted.jti(JWT ID) β unique token identifier; used for replay protection / revocation.
Verification, at minimum, must check: signature valid, exp not past, nbf not future, iss is who you expected, aud includes you. Skipping any of those is a bug.
Don't put secrets in the payload. The payload is Base64url, not encrypted. "Secrets" includes anything you wouldn't print to a log: full names, email addresses, internal user IDs you don't want exposed, anything subject to GDPR / CCPA on its own. If a claim must stay private, use JWE β or just don't put it in the token at all.
Symmetric vs asymmetric: HS vs RS vs ES
The signing algorithms break into two families:
- Symmetric (HS256, HS384, HS512) β HMAC with a shared secret. Issuer and verifier hold the same key. Cheap; small. Right when one party both issues and verifies (the same backend signing tokens for itself to verify on the next request).
- Asymmetric (RS256/RS384/RS512, PS256/PS384/PS512, ES256/ES384/ES512, EdDSA) β public/private keypair. Issuer signs with the private key; any verifier holds the public key. Right when many independent parties need to verify, e.g. OIDC.
RS256 is RSA with PKCS#1 v1.5 padding β secure but not the modern choice. PS256 is RSA-PSS, a stronger padding. ES256 is ECDSA over P-256. EdDSA (Ed25519) is the newest entrant and the modern recommendation when your library supports it. For most backends in 2026: prefer ES256 or EdDSA over RS256; reach for HS256 only when there's exactly one issuer and one verifier and they're the same service.
The alg header should always be in the verifier's allowlist. Never accept "any algorithm the token says."
Where JWTs are the right tool
The case for JWT in a system: verification without a database lookup. A signed JWT lets a backend trust a token's claims by checking a signature, without contacting the auth service for every request. That's the speed win.
Concretely: OIDC id_token, OAuth access_token in some flows, internal service-to-service tokens, federated identity across domains.
Where JWTs are the wrong tool
The case against JWT for sessions: a signed token is hard to revoke. A traditional session is a row in a database; deleting the row revokes the session immediately. A JWT is valid until exp, even after the user changes their password, gets fired, has their account compromised, or logs out. Building revocation requires either:
- A short
exp(5β15 minutes) plus a refresh-token round-trip β most of what was supposed to be the JWT speed win evaporates. - A blocklist of revoked
jtis consulted on every request β you've reinvented the database lookup you were trying to avoid. - A version number in the user record that's checked on every request β same problem.
For a typical web session β login, browse, logout β a server-side session backed by a cookie is simpler, easier to revoke, and uses less bandwidth. The JWT-as-session pattern that was popular 2015-2020 is in retreat for exactly this reason; reach for it only when the speed win actually matters and you can tolerate the revocation lag.
The argument also applies to mobile apps: many "JWT for mobile auth" systems would be cleaner with a server-side opaque token + a short /me endpoint.
Refresh tokens
The standard JWT auth pattern: short-lived access token (JWT, 5β15 min, sent on every request) plus long-lived refresh token (opaque, sent only to a /refresh endpoint). Compromised access token expires quickly. Compromised refresh token is revocable on the server. The refresh token doesn't need to be a JWT; making it opaque is usually better because you want server-side revocation control.
Rotate refresh tokens on every use ("rotating refresh tokens") to detect and shut down theft: if the same refresh token is used twice, the second use indicates a leak, and both tokens get burned. This is OAuth 2.1's recommendation.
Storage: cookies vs localStorage
Where the browser keeps the JWT determines the attack surface.
HttpOnlycookie β JS can't read it; an XSS-injected script can't steal it. Submitted automatically with same-site requests, which means CSRF is your concern instead β setSameSite=Lax(orStrict), and verify origin on state-changing requests. This is the safer default.localStorage/sessionStorageβ JS can read it; an XSS-injected script can read it. The token is now exposed to every script you ever load, including third-party scripts (analytics, ads, vendor SDKs). Avoid for high-value tokens.
There's no shortcut: if your app has any XSS vulnerability and you store JWTs in localStorage, your auth tokens are stealable by attackers. With HttpOnly cookies, an XSS attack still allows the attacker to make requests as the user (the cookie is sent automatically) β but they can't exfiltrate the token to use later.
What's actually inside a real JWT
An OIDC id_token from Google looks roughly like:
{
"iss": "https://accounts.google.com",
"azp": "client-id-of-the-app.apps.googleusercontent.com",
"aud": "client-id-of-the-app.apps.googleusercontent.com",
"sub": "1234567890",
"email": "alice@example.com",
"email_verified": true,
"iat": 1735689000,
"exp": 1735692600,
"nonce": "abc123"
}
To verify this you fetch Google's JWKS (a JSON document listing public keys, at a well-known URL like https://www.googleapis.com/oauth2/v3/certs), find the key matching the kid header on the token, and verify the signature. Then you check iss, aud, exp, and the nonce you sent.
The JWKS rotates periodically; libraries cache it with a short TTL so a key rotation doesn't break verification.
Common pitfalls
- Trusting the
algheader in the token. Pin the expected algorithm in the verifier; never acceptnone. - Algorithm confusion (HS/RS). Use type-strict verification APIs that reject unexpected algorithms.
- Not verifying signature at all. Decoding a token is trivial; that doesn't make the contents trustworthy.
- Not checking
exp/nbf/aud/iss. All four are necessary. - Putting PII or secrets in the payload. The payload is readable.
- Long-lived JWTs as sessions. Revocation becomes painful. Use short-lived access tokens + opaque refresh tokens.
- Storing JWTs in localStorage in apps with any XSS risk.
- Not handling clock skew.
expis a wall-clock check; allow a few minutes of leeway in either direction. - Reusing keys across environments. Dev's signing key should not validate prod's tokens.
- Hardcoded HMAC secrets in source code. Rotate them; load from secrets manager.
- Accepting any issuer. Pin to specific URLs, especially for federated logins.
When to choose JWT and when not to
Choose JWT when:
- You have multiple verifiers that need to validate tokens without contacting the issuer (microservices, federated identity, OIDC).
- The performance gain from skipping a database lookup is meaningful at your scale.
- The token's lifetime is short enough that lack of instant revocation is acceptable.
Choose an opaque token + server session when:
- You want logout / revoke / kick-out-this-user to take immediate effect.
- One service is both issuing and consuming tokens.
- You'd rather one Redis lookup per request than the JWT machinery.
The honest summary: JWT is excellent at the job it was designed for (federated, stateless verification across trust boundaries) and overused for the job everyone reaches for it (browser sessions). When in doubt, start with a session cookie. Promote to JWT only when the architecture requires it.
Decode and inspect any JWT
The JWT tool on this site decodes the header, payload, and signature locally β without ever sending the token off-device. Useful for inspecting what's inside an OIDC id_token or a third-party API token before you trust it. Nothing leaves your browser.
Open the JWT toolRelated guides
Keep the session useful with adjacent reading instead of exiting after one article.
QR Codes, Properly Explained
How QR codes actually work β finder patterns, Reed-Solomon error correction, static vs. dynamic redirects, and the real reasons codes fail in print.
Base64, Properly Explained
A 1989 hack for smuggling binary through 7-bit email transports β and why we still use it for JWTs, data URIs, and a hundred other places. Two alphabets, one common decode failure, and the things it categorically isn't.
URL Encoding, Properly Explained
Why %20 and + both mean space, why encodeURI and encodeURIComponent are not interchangeable, and how the HTML form spec quietly invented its own incompatible variant. RFC 3986 vs application/x-www-form-urlencoded.