What is a JWT?
A JWT (JSON Web Token, RFC 7519) is an open standard that packs a set of claims into a JSON object and signs it so the receiver can prove the content was not modified in transit. Note the precise boundary: a JWT guarantees integrity, not confidentiality. It lets a server confirm "I issued this token and nobody edited it" without touching a database.
Its dominant use case is authentication: a user logs in, receives an access token, and attaches it to every subsequent request. The server verifies the signature and identifies the user with no session store at all.
How it differs from a classic session
| Dimension | Session + cookie | JWT |
|---|---|---|
| Server state | Session store required (memory / Redis) | Stateless; the token is self-contained |
| Horizontal scaling | Needs shared storage or sticky sessions | Just add machines |
| Cross-domain / cross-service | Cookie domain rules get painful | Lives in the Authorization header, naturally cross-origin |
| Revocation | Delete it server-side and it dies | Not revocable by default; needs extra design |
| Cost per request | One storage lookup | One signature verification (usually faster) |
| Payload size | Just a session id | Carries every claim, so headers get bigger |
The takeaway is not "one is better". If you need instant revocation and tight control, sessions are less work. If you run multiple services, multiple clients, and need to scale out, JWTs fit better.
Taking a JWT apart by hand
A JWT is three Base64Url strings joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Drop it into the JWT parser and the three JSON segments appear instantly. To understand the mechanics, decode the first two segments yourself with the Base64 converter.
Segment 1: Header
{ "alg": "HS256", "typ": "JWT" }
| Field | Meaning | Common values |
|---|---|---|
alg |
Signing algorithm | HS256 / RS256 / ES256 / EdDSA / none |
typ |
Token type | JWT (at+jwt for nested cases) |
kid |
Key ID | Locates the public key in a JWKS; required for key rotation |
jku |
JWKS URL | Use with care — see the attacks section |
cty |
Content type | Used when nesting a JWS inside a JWE |
Segment 2: Payload
The payload is a list of claims in three categories.
Registered claims (defined by RFC 7519; all optional, all worth using)
| Claim | Meaning | Notes |
|---|---|---|
iss |
Issuer | Prefer a full URL such as https://auth.example.com |
sub |
Subject | Usually the user id — globally unique and never reused |
aud |
Audience | String or array; the verifier must find itself in it |
exp |
Expiration time | Unix timestamp in seconds |
nbf |
Not before | Tokens earlier than this must be rejected |
iat |
Issued at | Lets you compute token age |
jti |
JWT ID | The key to replay protection and deny-lists |
Public claims: must avoid collisions — register with IANA or namespace them with a URI, e.g. https://oltool.net/claims/role.
Private claims: whatever both sides agree on, such as role or tenant_id.
⚠️ Timestamps are in seconds, not milliseconds. Passing
Date.now()straight through yields an expiry 50 years out — one of the most common production blunders.
Segment 3: Signature
The signature covers the first two segments. For HS256:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
RS256 uses RSASSA-PKCS1-v1_5(SHA-256, signingInput), ES256 uses ECDSA(P-256, SHA-256). Notice the counter-intuitive part: the signature does not encrypt anything. It digests the exact byte string of the first two segments, then appends the Base64Url result. Verification simply recomputes it from the same input and compares.
Base64Url is not secrecy
This is the trap everyone falls into. Base64Url is just an encoding that makes binary safe for URLs; any decoder reverses it. Verify it yourself: copy the payload segment and decode it with the Base64 converter.
Safe to include: user id, roles, tenant, expiry, scopes, session identifier.
Never include: passwords, keys, national ID numbers, card numbers, full phone numbers, internal topology.
When you genuinely need confidentiality: that is JWE (RFC 7516), an encrypted token with a five-segment structure — a different standard from JWS. But in most cases the right answer is not JWE; it is not putting sensitive data in the token at all. A token exists to prove identity, not to act as a database row.
Choosing a signing algorithm
| Algorithm | Type | Key size | Signature size | Use when |
|---|---|---|---|---|
HS256 |
Symmetric HMAC | ≥ 256 bit random | 43 B | One service issues and verifies |
HS384 / HS512 |
Symmetric HMAC | ≥ 384 / 512 bit | 64 / 86 B | Compliance demands a stronger digest |
RS256 |
RSA PKCS#1 | ≥ 2048 bit (3072 preferred) | 342 B | Distributed systems where compatibility wins |
PS256 |
RSA-PSS | ≥ 2048 bit | 342 B | You want probabilistic signatures and padding-attack resistance |
ES256 |
ECDSA P-256 | 256 bit | 86 B | Mobile or bandwidth-sensitive clients |
EdDSA |
Ed25519 | 256 bit | 86 B | Greenfield systems; simple to implement, no nonce pitfalls |
Decision order:
- Issuer and verifier are the same single service →
HS256. - Multiple verifiers or third parties →
RS256(compatibility) orES256(shorter). - Brand-new system, both ends under your control →
EdDSA, avoiding ECDSA nonce-reuse risk. - Regulatory or national-algorithm requirements → follow the mandated suite (e.g. SM2).
You can mint a keypair with the RSA key generator, and see the difference between keyed and unkeyed digests by comparing the HMAC generator with the plain hash tool.
The seven-step verification checklist
Verification is not "the library returned true". This is the order production code must follow:
- Structure: exactly three segments, each valid Base64Url.
- Algorithm allow-list: read the permitted
algvalues from your own config — never let the token choose which key is used. - Resolve the key: by
kidwhen present; otherwise the single configured key. - Verify the signature: before parsing any claim.
- Time: check
exp(with leeway),nbf, and thatiatis not in the future. - Origin: check
issandaudagainst this service. - Business rules: is
jtideny-listed, is the user still active, do the scopes cover the request?
Step 2 is the one that gets skipped. Almost no JWT breach breaks the crypto; they all exploit a server that trusted the algorithm declared inside the token.
// ✅ Correct: the algorithm is pinned on the verifying side
jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer, audience });
// ❌ Dangerous: the library picks the algorithm from the token
jwt.verify(token, key); // an attacker switches to HS256 and signs with your public key
Seven classic attacks and their defences
| Attack | How it works | Defence |
|---|---|---|
alg: none |
Strip the signature; a server that does not pin the algorithm accepts it | Enforce an allow-list and reject none explicitly |
| Algorithm confusion | An RS256 service is fed an HS256 token signed with the public key as the HMAC secret | Pin both algorithm and key type at verification time |
| Weak-secret brute force | HS256 with secret / 123456 can be enumerated offline |
≥ 256 bits of randomness, rotate regularly |
JWKS / jku spoofing |
The attacker points jku at their own key set |
Ignore jku / x5u; load keys only from local config or a pinned JWKS endpoint |
kid injection |
kid is concatenated into a file path or SQL fragment |
Use kid only as a dictionary key — never in paths or queries |
| XSS theft | The token sits in localStorage and is read by injected script | See the storage FAQ, plus a strict CSP |
| Replay | A captured token is reused | Short exp, one-time jti checks, and HTTPS everywhere |
One corollary: the blast radius of a leaked token equals its remaining lifetime. Shrinking an access token from 7 days to 15 minutes is the highest return-on-effort security change on this list.
Lifecycle and revocation
The two-token model
| Token | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access token | 5–15 minutes | Memory / Authorization header |
Calling business APIs |
| Refresh token | 7–30 days | HttpOnly cookie scoped to /refresh |
Minting new access tokens |
Refresh tokens must rotate: every exchange issues a new one and invalidates the old. If an already-retired refresh token shows up again, the token has leaked — revoke the entire chain and force re-authentication.
Three revocation strategies compared
| Strategy | Implementation | Effect delay | Cost |
|---|---|---|---|
| Short lifetime | Config change | ≤ access-token lifetime | More refresh traffic |
Deny-list (jti) |
Redis holding revoked, not-yet-expired ids | Immediate | Reintroduces state; only store unexpired ids so they age out |
| Version number | A token_version column, embedded in the payload, compared on verify |
Immediate | One lookup per verification (cacheable) |
Compliance scenarios that need "log this person out now" justify a deny-list. Everything else is fine with short lifetimes plus a version column.
Status codes that go with it
Return these for token failures, and cross-check with the HTTP status code reference:
| Situation | Status | Header |
|---|---|---|
| Token expired | 401 |
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired" |
| Invalid signature | 401 |
Same, and log the jti for tracing |
| Insufficient scope | 403 |
Do not downgrade to 404 — they mean different things |
| Refresh token rejected | 401 |
Clear the cookie and force re-login |
Recommended libraries
| Language | Libraries | Notes |
|---|---|---|
| Node.js | jose (preferred), jsonwebtoken |
With jsonwebtoken you must pass algorithms explicitly |
| Python | PyJWT, python-jose |
PyJWT leaves iss / aud checks to you |
| Java | nimbus-jose-jwt, jjwt |
Spring Security ships NimbusJwtDecoder |
| Go | golang-jwt/jwt/v5, lestrrat-go/jwx |
v5 requires declaring algorithms up front |
| PHP | firebase/php-jwt, lcobucci/jwt |
The latter has a more modern API |
| Rust | jsonwebtoken |
Mind the clock leeway setting |
When not to use a JWT
- Admin back-offices needing second-level revocation — sessions are simpler and safer.
- Treating the JWT as a full session bucket — the payload travels on every request; overstuffing slows APIs and can blow past gateway header limits (some cap at 8 KB).
- Using it instead of encryption for sensitive data — that is JWE's or TLS's job.
- A simple monolith login — the added complexity may buy you nothing.
Pre-launch checklist
- [ ]
algcomes from a server-side allow-list, never from the token - [ ] Key is ≥ 256 bits of randomness and never committed
- [ ]
exp/nbf/iatuse second-precision timestamps - [ ]
issandaudare verified - [ ] Leeway set to 30–60 seconds
- [ ] No sensitive data anywhere in the payload
- [ ] Access token ≤ 15 minutes, refresh tokens rotate
- [ ]
jku/x5uignored; key source is pinned - [ ] HTTPS everywhere; tokens never in URLs or logs
- [ ]
jtipresent, with a working revocation path
Open the JWT parser and paste any token in to see the three segments — the whole process runs in your browser, and the token never leaves your machine.