What a JWT actually is
A JSON Web Token (specified in RFC 7519) is three base64url-encoded strings joined by dots: header.payload.signature. The header says what algorithm signed it. The payload is a JSON object with claims (who issued it, who it's for, when it expires). The signature is what makes the first two parts trustworthy — change a byte in the payload and the signature stops verifying.
The format itself is dull on purpose. Base64url, JSON, a hash or signature. It deliberately does not encrypt the payload — anyone holding a JWT can read the claims by base64url-decoding the middle segment. The token is signed, not sealed. If you need confidentiality of the payload, you want JWE (JSON Web Encryption), not JWS (the signed variant almost everyone means when they say 'JWT').
Anatomy: header, payload, signature
The header is a JSON object like {"alg":"HS256","typ":"JWT"}. alg is the signature algorithm — HS256 (HMAC-SHA256, symmetric) and RS256 (RSA-SHA256, asymmetric) are the two you'll see most often. ES256 (ECDSA on P-256) is the modern recommendation when you control both sides. kid is a key id that tells the verifier which public key from a JWKS to use.
The payload is a JSON object of claims. The standard ones registered by IANA are: iss (issuer), sub (subject), aud (audience), exp (expiry, Unix seconds), iat (issued at), nbf (not before), and jti (a unique id, useful for replay tracking). You can add anything else you want, but keep it small — tokens travel in HTTP headers.
The signature is computed over base64url(header) + '.' + base64url(payload), using the algorithm named in the header and the appropriate key. The verifier recomputes the same thing with the matching key and compares — in constant time, to avoid timing attacks. If the recomputed signature doesn't match, the token is rejected before any claim is trusted.
The classic attacks — and the libraries that quietly fix them
alg:none. The original spec allowed an algorithm of 'none', meaning the signature segment is empty. An attacker takes a real token, edits the payload ('role':'admin'), changes the header to {'alg':'none'}, drops the signature, and submits it. A lazy verifier that just calls decode() and trusts the result will accept it. Every modern library refuses alg:none by default.
Key confusion (HS256/RS256). Your server has an RSA public key it uses to verify RS256 tokens. An attacker grabs that public key (it's, by definition, public), then mints a forged token signed with HS256 using the public key bytes as the HMAC secret. If the verifier blindly uses the algorithm from the token header and feeds the same public key in, the HMAC verification succeeds. The fix: pin the expected algorithm at verification time, never derive it from the token's own header.
Replay and expiry. A leaked JWT is valid until it expires. If your exp is a week away, that's a week of attacker access. Keep access tokens short (5–15 minutes), use a longer-lived refresh token kept in an HttpOnly cookie, and maintain a jti deny-list for any token you actively revoke (logout, compromise, role change).
Algorithm downgrade and missing audience checks. Always verify iss, aud, and exp explicitly. A token minted by your auth server for a different audience should not be accepted by your API. Libraries default to checking exp; iss and aud are usually opt-in flags you must pass.
Inspecting a token safely
Never paste a production JWT into a random online decoder. The token is a bearer credential — whoever holds it can act as the user it represents until it expires. 'Decode JWT' sites that send the token to a server are a credential-leak waiting to happen. Use a local-first decoder that does the base64url decode in your own browser tab.
The SnapToolz JWT decoder runs entirely in the page — no upload, no network call. Paste the token, see the header and payload pretty-printed, watch exp render as a human-readable date, and verify the signature against a key you also paste locally. Close the tab and nothing about the token persists.
When debugging, the things to check in order are: is the alg what you expect? is iss the issuer you trust? is aud your service? is the token within nbf and exp? does kid map to a key your JWKS knows about? does the signature actually verify with that key? Most production JWT bugs collapse to one of these six checks being silently skipped.
JWE vs JWS, and when to skip JWT entirely
JWS is the signed variant — the JWT everyone means. JWE is the encrypted variant: the payload is sealed with a content encryption key. Use JWE when the claims contain anything you don't want intermediaries to read (PII, internal user ids, entitlement flags that would help an attacker enumerate features).
JWT is not always the right answer. An opaque session token — a 256-bit random string stored server-side in Redis or a database — is simpler, smaller, instantly revocable, and leaks zero information to the client. If your tokens never leave your own infrastructure and you don't need a third party to verify them statelessly, opaque tokens are usually the better choice. JWTs shine when you want stateless verification across services without those services needing to call back to your auth database on every request.
Refresh tokens are the standard pattern for handling the short-lived-access tension. Issue a short-lived JWT access token (5–15 minutes) plus a longer-lived refresh token (days to weeks). The refresh token is opaque, single-use (rotated on every refresh), and stored in an HttpOnly + Secure + SameSite cookie.
Tools used in this guide
FAQ
- Is it safe to store a JWT in localStorage?
- Functionally yes, security-wise it depends. Anything in localStorage is readable by any script that runs on your origin — so a single XSS bug exfiltrates every user's token. HttpOnly cookies are immune to that vector but introduce CSRF concerns you handle with SameSite=Lax or a CSRF token. The modern guidance is: refresh token in HttpOnly cookie, short-lived access token in memory (not localStorage), rotated on every refresh.
- How long should an access token live?
- Short — 5 to 15 minutes is the common range. The trade-off is that a compromised token is valid for that window; shorter is safer but increases load on your auth endpoint. Pair short access tokens with a refresh token that does the heavy lifting on each refresh.
- Can I trust the claims in a JWT without verifying the signature?
- No. Decoding a JWT is just base64url — anyone can produce a string with whatever claims they want. The signature is the only thing tying the claims to whoever holds the signing key. Always verify before trusting any field, including sub. Never call jwt.decode() (unverified) on a token you intend to act on; call jwt.verify().
- What's a JWKS and why do I need one?
- A JWKS (JSON Web Key Set) is a JSON document published by your auth server at a known URL (usually /.well-known/jwks.json) that lists the public keys it currently uses to sign tokens, each tagged with a kid. Your services fetch and cache the JWKS, then use the kid in an incoming token's header to pick the right key. This is what lets you rotate signing keys without coordinating a flag day across every service.