What Is a JWT?

A JSON Web Token is how most modern APIs prove "this request came from someone who already logged in" without the server having to remember anything. Here's what's actually inside one, and the two mistakes that account for most JWT-related security bugs.

reference guide auth JSON Web Token

// the shape of a jwt

│

A JWT is three Base64URL-encoded segments joined by dots: header.payload.signature. Base64URL is the same idea as regular Base64 (see the Base64 Converter) but swaps +// for -/_ so the token is safe to put directly in a URL or an HTTP header without escaping.

the three parts
headerwhich algorithm signed this token — e.g. {"alg":"HS256","typ":"JWT"}
payloadthe claims — who this token represents and when it expires
signatureproof the header + payload weren't tampered with since signing

// how the signature is actually computed

│

The signature isn't computed over the decoded JSON — it's computed over the two encoded segments exactly as they appear in the token, joined by a dot: base64url(header) + "." + base64url(payload). That concatenated string is fed into the algorithm named in the header, along with either a shared secret (HS256) or a private key (RS256/ES256), and the output is base64url-encoded and appended as the third segment. Verifying is the same computation run again on the received header and payload — if the freshly computed signature doesn't byte-for-byte match the one on the token, verification fails.

base64url(header).base64url(payload)HMAC-SHA256(secret) or RSA/ECDSA-sign(private key)base64url(signature)← appended as the third segment
The signature covers the encoded header and payload joined by a dot — not the decoded JSON. Verification recomputes this same operation and compares the result byte-for-byte to the signature on the token.

// claims worth knowing

registered claims
ississuer — who created and signed this token
subsubject — who the token is about, usually a user ID
audaudience — who this token is intended for
expexpiration — a Unix timestamp; the token is invalid after this
nbfnot-before — the token isn't valid until this timestamp
iatissued-at — when the token was created

Everything past these is application-specific — roles, permissions, tenant IDs, whatever the issuer decided to include. There's no schema enforcement; the payload is just JSON, so anything the signer puts there arrives at the consumer as-is. exp, nbf, and iat are ordinary Unix timestamps in seconds — see Unix Timestamp & Epoch Time Explained if a token looks expired the moment it's issued, which is usually a seconds/milliseconds unit mismatch, not a clock problem.

// symmetric vs asymmetric signing

│

HS256 uses one shared secret to both sign and verify — simple, but every service that needs to verify a token also needs the secret that can forge one. RS256 and ES256 use a private/public key pair: only the issuer holds the private key that signs tokens, while any number of services can hold the public key and verify without ever being able to mint a valid token themselves. For anything beyond a single trusted backend, asymmetric signing is almost always the right default. HS256 specifically is HMAC-SHA256 under the hood — see Hashing Algorithms Explained for what HMAC actually does differently from a plain hash.

// the algorithm-confusion attack

│

The header's alg field is part of the token an attacker fully controls before signing — which means a verifier that blindly trusts it can be tricked into checking a signature the wrong way. The classic case: a service issues RS256 tokens and publishes its RSA public key (public keys are meant to be shared). If the verification code reads alg from the token and uses whatever algorithm it says, an attacker can craft a token with "alg":"HS256" and sign it using the public key as an HMAC secret. A verifier that obeys the header runs HMAC-SHA256 with that same public key, gets a match, and accepts a token it never issued.

A related, older bug: some early JWT libraries accepted "alg":"none" and skipped signature verification entirely, treating an unsigned token as valid. Both issues have the same fix — never let the token's own header choose how it gets verified. Pin the expected algorithm (and reject none) in the verification call itself; every actively maintained JWT library supports this, and most enable it by default today.

// where should a JWT actually live in the browser

│

Since a JWT is bearer proof of identity — anyone holding it can use it — where it sits between requests matters as much as how it's signed.

the two common choices
localStorage / sessionStoragereadable by any JS on the page — a single XSS bug anywhere in the app can exfiltrate every stored token
httpOnly cookieinvisible to JS, so XSS can't read it directly — but sent automatically by the browser, which reopens the door to CSRF unless paired with SameSite and a CSRF token

There's no option that's simply "safe" — an httpOnly cookie trades an XSS-theft risk for a CSRF risk that needs its own mitigation (SameSite=Strict or Lax, plus a double-submit or synchronizer token for state-changing requests). Whichever is chosen, keep the token's lifetime short — the storage decision only limits how a stolen token gets used, not whether the underlying app vulnerability exists.

// JWT vs session cookies

stateless vs stateful
stateJWT: none held server-side, the token itself carries the claims · session cookie: server stores the session, the cookie holds only an opaque ID
revocationJWT: can't be invalidated before exp without extra infrastructure · session: delete the server-side record, effective immediately
scalingJWT: any server that holds the verification key can validate independently · session: needs a shared store (Redis, DB) across instances, or sticky sessions
sizeJWT: sent on every request, typically several hundred bytes to a few KB · session: cookie is tiny, just an ID
best fitJWT: cross-service auth, mobile/SPA clients talking to independently-scaled APIs · session: a single monolith or anywhere easy server-side revocation matters more than statelessness

// can a JWT be revoked before it expires?

│

Not natively — that statelessness is the whole point of a JWT, and it's also its biggest operational drawback. A stolen or leaked token stays valid until exp no matter what the issuer does afterward, unless one of these patterns is added on top:

common workarounds
short expiry + refresh tokenskeep the JWT itself short-lived (minutes), issue a separate longer-lived refresh token the server can revoke, used to mint new JWTs
a denyliststore revoked token IDs (jti) somewhere fast (Redis) and check it on every request — reintroduces server state, defeating part of the point
a version claimstamp a tokenVersion claim from the user record; bump the stored version to invalidate every JWT issued before that point in one write

There's no way around it: full statelessness and instant revocation are in direct tension, and every real system picks a point on that trade-off rather than getting both for free.

// the two mistakes that matter

A JWT is encoded, not encrypted. Base64URL is trivially reversible by anyone — there is no key required to read a JWT's header or payload, only to verify its signature. Never put a password, secret, or anything sensitive directly in the payload; assume every claim is public the moment the token exists.
Always verify the signature server-side before trusting any claim. A client can send back any JWT-shaped string it wants, including one with exp pushed into the future or role: admin added by hand. The signature is the only thing that proves the payload wasn't altered after the real issuer signed it — reading claims without verifying first means trusting user input.

// frequently asked questions

Is a JWT encrypted?

No — it's encoded, not encrypted. Base64URL is trivially reversible by anyone; no key is required to read a JWT's header or payload, only to verify its signature. Never put a password, secret, or anything sensitive directly in the payload; assume every claim is public the moment the token exists.

How is a JWT's signature actually computed?

Over the two encoded segments exactly as they appear, joined by a dot: base64url(header) + "." + base64url(payload). That string is fed into the algorithm named in the header along with a shared secret (HS256) or private key (RS256/ES256), and the result is base64url-encoded as the third segment. Verification recomputes this and compares byte-for-byte.

What is the JWT algorithm-confusion attack?

A verifier that blindly trusts the token's own alg header can be tricked: if a service issues RS256 tokens and publishes its RSA public key, an attacker can craft a token with "alg":"HS256" signed using that public key as an HMAC secret. A verifier that obeys the header runs HMAC-SHA256 with the same public key, gets a match, and accepts a forged token. The fix is to pin the expected algorithm in the verification call itself, never let the header choose it.

Can a JWT be revoked before it expires?

Not natively — statelessness is the whole point of a JWT, and also its biggest operational drawback. A stolen token stays valid until exp regardless of what the issuer does, unless something is added on top: short expiry with revocable refresh tokens, a denylist of revoked token IDs, or a version claim that invalidates every token issued before a bump.

Should a JWT be stored in localStorage or a cookie?

Neither is simply "safe." localStorage is readable by any JS on the page, so a single XSS bug can exfiltrate every stored token. An httpOnly cookie is invisible to JS but sent automatically by the browser, reopening CSRF risk unless paired with SameSite and a CSRF token. Whichever is chosen, keep the token's lifetime short.

// try it yourself

│

Paste a token into the JWT Decoder to see its header and payload broken out, check exp/nbf against the current time, and verify HS256, RS256, or ES256 signatures — entirely in your browser, the token never leaves your machine.