A JSON Web Token is a compact, URL-safe token that carries signed JSON claims, letting servers verify requests without stored sessions. That is RFC 7519’s own framing — “a compact, URL-safe means of representing claims to be transferred between two parties” — and the two ideas in it carry everything that follows: the token contains its facts, and a signature makes those facts checkable by anyone holding the right key, with no database in the loop.
Key takeaways
| Question | Answer |
|---|---|
| The shape | header.payload.signature — three Base64Url parts joined by dots |
| The trick | Anyone can decode it; only key-holders can forge it |
| Not encryption | The payload is readable — signed ≠ secret (that’s JWE’s job) |
| The trade | Stateless verification ↔ no built-in revocation until exp |
| The discipline | Pin algorithms · validate iss/aud/exp · short lifetimes + refresh rotation |
A JWT, decoded
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c3ItOGZrMiIsInJvbGUi… . dBjftJeZ4CVP…
header { "alg": "HS256", "typ": "JWT" }
payload { "sub": "usr-8fk2", "role": "editor",
"iss": "https://api.example.com", "aud": "example-web",
"iat": 1767024900, "exp": 1767025800 } ← 15-minute life
signature HMACSHA256( base64url(header) + "." + base64url(payload), secret )
Anyone can DECODE the first two parts — Base64Url is packaging, not encryption.
Only key-holders can FORGE the third — which is the entire trick.
The contrast worth seeing in code — the stateful alternative every JWT decision is measured against:
// JavaScript / Node.js — Back4app JS SDK
// The stateful contrast: Back4app issues revocable session tokens
const user = await Parse.User.logIn('ada', 'correct-horse-battery');
const token = user.getSessionToken(); // r:abc123… — opaque, server-side state
// Every request presents it; the server can revoke it instantly:
await Parse.User.logOut(); // token invalid NOW — no waiting for an exp claim // Flutter / Dart — Back4app Flutter SDK
// The stateful contrast: Back4app issues revocable session tokens
final user = ParseUser('ada', 'correct-horse-battery', null);
await user.login();
final token = user.sessionToken; // r:abc123… — opaque, server-side state
// Every request presents it; the server can revoke it instantly:
await user.logout(); // token invalid NOW — no waiting for an exp claim // iOS / Swift — Back4app Swift SDK
// The stateful contrast: Back4app issues revocable session tokens
let user = try await User.login(username: "ada", password: "correct-horse-battery")
let token = user.sessionToken // r:abc123… — opaque, server-side state
// Every request presents it; the server can revoke it instantly:
try await User.logout() // token invalid NOW — no waiting for an exp claim // Android / Kotlin — Back4app Android SDK
// The stateful contrast: Back4app issues revocable session tokens
val user = ParseUser.logIn("ada", "correct-horse-battery")
val token = user.sessionToken // r:abc123… — opaque, server-side state
// Every request presents it; the server can revoke it instantly:
ParseUser.logOut() // token invalid NOW — no waiting for an exp claim How verification works
Precision the explainers skip: “JWT” names the claims format; what everyone actually passes around is a JWS (RFC 7515) — the signed serialization — while JWE is the encrypted sibling for payloads that must stay unreadable. And verification is two jobs, not one: check the signature, then validate the claims — exp and nbf against the clock, iss against your allowlist of issuers, aud against this service’s identifier. A perfectly signed token that’s expired, from the wrong issuer, or minted for a different audience is a perfectly signed attack.
Claims: the payload’s vocabulary
| Claim | Name | Verifier’s job |
|---|---|---|
iss | Issuer | Is this an issuer I trust? |
sub | Subject | Who is this about — the stable user ID |
aud | Audience | Was this minted for me? Reject others’ tokens |
exp | Expiration | Reject after this Unix timestamp |
iat / nbf | Issued at / not before | Sanity-check the validity window |
jti | Token ID | Unique handle — the hook a denylist needs |
| custom | Roles, tenant, plan… | App semantics — keep minimal, never secret |
Keep payloads lean twice over: tokens ride every request as headers (cookies cap near 4 KB, and each claim is repeated bandwidth), and everything in them is legible to whoever holds the token.
JWT vs. session tokens
| JWT (stateless) | Session token (stateful) | |
|---|---|---|
| The token is | The state itself, signed | An opaque pointer to server state |
| Per-request cost | Signature check, no lookup | One session-store lookup |
| Revocation | None until exp — by design | Instant — delete the session |
| Cross-service auth | Any service with the key verifies | Services must share the session store |
| Logout means | Client discards; token stays valid | Token actually dies |
| Best home | APIs, microservices, third-party verification | Single-backend apps, sensitive sessions |
The unfashionable truth several best-practice guides now lead with: for a server-rendered app with one backend, framework sessions are simpler and more controllable — JWTs earn their keep when tokens must be verified across services or by parties who shouldn’t phone home per request.
The revocation problem, honestly
Statelessness is the inability to revoke — the same property, described twice. A signed token is valid until exp no matter what happened since: logout, password change, account ban. Every fix reintroduces state, so choose which flavor: a denylist keyed on jti + iss (the OWASP-recommended construction) checked per request — small state, but state; key versioning, which revokes everyone at once; or the standard architecture — short-lived access tokens (5–15 minutes) plus revocable refresh tokens with rotation: each refresh issues a new refresh token and retires the old, so a stolen one dies on first replay, and reuse of a retired token flags theft and kills the whole token family. Revocation latency then equals the access token’s lifetime — which is the real reason those lifetimes are short.
Choosing an algorithm — and the attacks on the choice
| HS256 (HMAC) | RS256 (RSA) | EdDSA / ES256 | |
|---|---|---|---|
| Keys | One shared secret | Private signs, public verifies | Private signs, public verifies |
| Verifiers need | The secret (can also forge!) | Public key only | Public key only |
| Fits | Issuer = verifier, single party | Multi-service, third-party | Same, smaller/faster signatures |
| Sharp edge | Short secrets brute-force offline from one captured token — use ≥256 random bits | Larger tokens, slower | Newer library support |
The attack section RFC 8725 exists for, in three sentences. alg: "none" is a legal header value — libraries that honor it accept unsigned tokens. The confusion attack: a verifier that lets the token’s header pick the algorithm can be handed an “HS256” token signed with the server’s public RSA key as the HMAC secret — a public value used as a private one. Both die the same way: the verifier pins its accepted algorithms and keys in configuration and never trusts the header to choose.
Where to store JWTs in a browser
| Location | XSS steals it? | CSRF sends it? | Survives refresh? | Verdict |
|---|---|---|---|---|
| localStorage | Yes | No | Yes | Avoid — script-readable |
| Plain cookie | Yes (script-readable) | Yes | Yes | Worst of both |
| HttpOnly + Secure + SameSite cookie | No | Mitigated by SameSite | Yes | Good — for the refresh token |
| In memory | Only while injected | No | No | Good — for the access token |
| Backend-for-frontend holds tokens | Browser never has them | Cookie rules apply | Yes | Strongest for SPAs |
The threat model, not the folklore: localStorage trades CSRF-immunity for XSS-theft, cookies trade the reverse — which is why the consensus splits the pair: access token in memory, refresh token in a hardened cookie.
Common use cases
- API authentication — the bearer token behind
Authorizationheaders on REST and GraphQL APIs. - Microservice identity — one gateway-issued token verified independently by every service, no shared session store.
- OpenID Connect ID tokens — the signed identity assertion in every social login — always a JWT.
- Cross-system handoffs — signed links, webhook payloads, download grants: claims verified by a party that can’t call you back.
- Stateless authorization hints — roles and tenant IDs carried in claims, with the authoritative check still server-side.
Should you use JWTs or sessions? A decision matrix
| Your situation | Reach for |
|---|---|
| Single backend, server-rendered app | Sessions — simpler, instantly revocable |
| Public API consumed by many services | JWTs, asymmetric keys |
| Microservices behind a gateway | JWTs — verify locally, no shared store |
| Instant lockout is a hard requirement | Sessions, or JWTs + denylist and short exp |
| Third parties must verify your assertions | JWTs — that’s the format’s home game |
| Mobile app against a BaaS | The platform’s session mechanism — it chose already |
Limitations and trade-offs
- Irrevocability is structural. Every mitigation — denylists, short lifetimes, rotation — is a partial return of the state you removed; price that in before choosing stateless.
- Claims go stale. Roles snapshot at issuance; a demoted admin stays an admin until expiry. Short lifetimes bound the staleness window.
- The payload is public. Treat it as readable by the user and any token thief — identifiers yes, secrets and PII no.
- Library defaults have burned people. Algorithm pinning, claim validation, and
typchecking are your configuration responsibilities, not the library’s guarantees. - Size compounds. Every claim rides every request; generous payloads tax mobile bandwidth and can overflow cookie limits.
Tokens on Back4app
Back4app is an open-source Backend-as-a-Service (BaaS) platform that combines a managed database, auto-generated REST and GraphQL APIs, authentication, file storage, and Cloud Code serverless functions. The platform’s own choice illustrates the decision matrix: client sessions use revocable, server-side session tokens — the code tabs above — so logout, password changes, and dashboard-side session termination take effect immediately, with no expiry window to wait out. JWTs appear where they belong: identity providers’ OIDC ID tokens are verified server-side by auth adapters during social login, and Cloud Code functions can mint or verify JWTs for third-party handoffs using standard libraries — with the signing secret in server-side configuration, never shipped to clients. Stateful where control matters, stateless at the edges where verification must travel: the architecture this article argues for, preassembled.
Frequently asked questions
What is a JWT in simple terms?
A signed, Base64-encoded JSON "ID card" a server issues at login. The client presents it on every request, and the server verifies the signature instead of looking up a session — the token itself carries who you are and until when. Officially it rhymes with "jot," per the RFC that defines it.
What are the three parts of a JWT?
Header (token type and signing algorithm), payload (the claims — the actual JSON data), and signature, each Base64Url-encoded and joined by dots into header.payload.signature. The signature is computed over the first two parts, so any tampering with them breaks verification.
Is a JWT encrypted?
No — encoded, not encrypted. Base64Url is a transport format anyone can reverse; paste any JWT into a decoder and the payload is readable. The signature prevents tampering, not reading. Never put secrets or sensitive personal data in a JWT payload; the encrypted variant (JWE) exists for genuine confidentiality needs.
What is the difference between a JWT and a session token?
Where the state lives. A session token is an opaque ID pointing at server-side state — one lookup per request, revocable instantly. A JWT carries the state inside itself — no lookup, verifiable by any service holding the key, but valid until expiry no matter what. Stateless scaling versus instant control.
Where should I store a JWT in the browser?
Not in localStorage — any injected script can read it, making XSS a token theft. Current consensus: keep the access token in memory, the refresh token in an HttpOnly, Secure, SameSite cookie, and consider the backend-for-frontend pattern that keeps tokens out of the browser entirely.
Can a JWT be revoked?
Not by design — a signed token is valid until its exp claim, which is the price of statelessness. The workarounds all reintroduce state: a denylist keyed on the jti claim, versioned keys, or — the standard answer — very short access-token lifetimes paired with revocable, rotating refresh tokens.
What are JWT claims?
The payload's fields. The RFC registers seven: iss (issuer), sub (subject), aud (audience), exp (expiration), iat (issued at), nbf (not before), and jti (token ID) — plus whatever custom claims you add, like roles. Verifiers must actually validate them; a signature check alone accepts expired tokens meant for other services.
What is the difference between HS256 and RS256?
Symmetry. HS256 signs and verifies with one shared secret — fine only when issuer and verifier are the same party, and only with a long random secret. RS256 (and the newer EdDSA) signs with a private key while anyone verifies with the public one — the default whenever more than one service checks tokens.
Is JWT secure?
The format is sound; implementations fail. The classic holes: accepting alg none, letting the token's header choose the algorithm (the RS256-to-HS256 confusion attack), brute-forceable short HMAC secrets, and skipping aud/iss/exp validation. Pin your algorithms, validate every claim, keep lifetimes short.
What is the difference between JWT and OAuth?
Different categories: JWT is a token format; OAuth 2.0 is an authorization framework. They compose — OAuth flows commonly issue access tokens that happen to be JWTs, and OpenID Connect ID tokens always are. One says how a token is built; the other says how tokens get handed out.