A JSON Web Token is a compact, URL-safe string that carries a set of claims (who the subject is, who issued the token, who it is for, when it expires) protected by a signature or by encryption. Its appeal is that any service holding the right key can check the token locally, without calling the issuer. Its danger is the same property: a token that verifies is trusted, so every decision about algorithms, keys, claims and lifetimes made at issue time shapes your security for as long as the token lives.
This article takes the issuer's side. It decodes a real token by hand, explains JWS and JWE, compares signing algorithms, designs a claim set, issues tokens in Python, and walks through key rotation with JWKS. Verification is summarised as a checklist; the bypasses are covered in a separate article linked at the end.
Where a JWT travels
Anatomy: a token decoded by hand
A signed JWT in compact form is three base64url strings separated by dots: header, payload and signature. Base64url is ordinary base64 with - and _ instead of + and /, and without = padding, so the token survives URLs and headers. It is encoding, not encryption: anyone holding the token can read every claim.
Here is a real HS256 token, computed with the Python standard library for this article using the throwaway secret demo-secret-do-not-use:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTQ4MjEiLCJhdWQiOiJvcmRlcnMtYXBpIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjE3OTAwMDA5MDB9
.9_2OBdDDjYwMoh9P9H6JHq43KmSyxUKQv9NawhikVes
header = {"alg":"HS256","typ":"JWT"}
payload = {"iss":"https://auth.example.com","sub":"user-4821","aud":"orders-api",
"iat":1790000000,"exp":1790000900}The signature is computed over the ASCII bytes of <header>.<payload>, exactly as encoded, not over re-serialised JSON. That is why verifiers must check the received segments rather than parse and re-encode them. The whole token is 221 characters; iat is 2026-09-21 14:13:20 UTC and exp is 15 minutes later. The construction fits in a few lines:
import base64, hashlib, hmac, json
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def sign_hs256(claims: dict, secret: bytes) -> str:
header = {"alg": "HS256", "typ": "JWT"}
h = b64url(json.dumps(header, separators=(",", ":")).encode())
p = b64url(json.dumps(claims, separators=(",", ":")).encode())
mac = hmac.new(secret, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
return f"{h}.{p}.{b64url(mac)}"Write this once to understand the format, then use a maintained library for real tokens; the library's value is mostly in verification, where hand-written code goes wrong.
JWS, JWE and the JOSE family
JWT (RFC 7519) defines the claim set. The protection comes from the wider JOSE family: JWS (RFC 7515) for signed tokens, JWE (RFC 7516) for encrypted ones, JWK (RFC 7517) for representing keys as JSON, and JWA (RFC 7518) for algorithm names. Nearly every token you meet is a JWS: readable by anyone, unforgeable without the key. A JWE has five segments (header, encrypted key, IV, ciphertext, tag) and hides the claims from the client as well as from anyone in between.
Use JWE only when the client or an intermediary must not read the claims, for example tokens that carry personal data through a browser. Often a better answer is to keep sensitive data out of the token entirely and let the resource server look it up. Nested sign-then-encrypt exists but doubles the key management, and most systems never need it.
Choosing a signing algorithm
The alg header names how the token was protected. Four families matter in practice:
| alg | Key type | Who can mint tokens | Signature size | Notes |
|---|---|---|---|---|
HS256 | Shared secret (HMAC-SHA-256) | Anyone who can verify | 32 bytes, 43 chars | Fine inside one service; wrong when many services verify |
RS256 | RSA 2048+ bit, PKCS#1 v1.5 | Only the private-key holder | 256 bytes, 342 chars (2048-bit) | Widest support; large tokens |
PS256 | RSA, PSS padding | Only the private-key holder | Same as RS256 | Preferred RSA padding where supported |
ES256 | ECDSA P-256 | Only the private-key holder | 64 bytes, 86 chars | Small; needs a good random source when signing |
EdDSA | Ed25519 | Only the private-key holder | 64 bytes, 86 chars | Deterministic, fast; check library support |
The deciding question is who verifies. With HMAC, verifying and minting use the same secret, so every service that can check a token can also forge one for any user. As soon as a second service verifies tokens, use an asymmetric algorithm so only the issuer holds the signing key. Between the asymmetric options, ES256 or EdDSA give tokens roughly 250 characters shorter than RS256, which matters when tokens ride on every request and in cookies. Whatever you choose, pin it: the issuer signs with one configured algorithm and verifiers accept only that list. The none algorithm must never be accepted, and RFC 8725 (JWT Best Current Practices) says the same.
Designing the claim set
RFC 7519 registers seven claims: iss (issuer), sub (subject), aud (audience), exp (expiry), nbf (not before), iat (issued at) and jti (token ID). Times are seconds since the epoch. For OAuth access tokens, RFC 9068 profiles these: it sets the header typ to at+jwt so an ID token cannot be replayed as an access token, and requires iss, exp, aud, sub, client_id, iat and jti.
Design rules that hold up:
- One audience per token, naming the API that should accept it. A token for
orders-apimust fail atbilling-api. - Stable, opaque subject identifiers, never e-mail addresses, which change and leak personal data into every log that records a token.
- Coarse permissions, not data. Put
scopeor a few roles in the token, and look up fine-grained permissions at the resource server. Tokens with hundreds of groups grow past header limits. - Namespace private claims (
https://example.com/tenant) so they cannot collide with registered or future claims. - A
jtion every token, which costs nothing and enables replay detection and targeted deny-lists later.
Issuing and verifying in code
Issuing with PyJWT and an asymmetric key, with the key ID in the header so verifiers know which public key to use:
import time, uuid
import jwt # PyJWT, with the "cryptography" extra installed for ES256
ISSUER = "https://auth.example.com"
ACTIVE_KID = "2026-10-a"
def issue_access_token(private_key_pem: bytes, user_id: str, client_id: str,
audience: str, scopes: list[str], ttl: int = 600) -> str:
now = int(time.time())
claims = {
"iss": ISSUER, "sub": user_id, "aud": audience, "client_id": client_id,
"iat": now, "nbf": now, "exp": now + ttl, "jti": str(uuid.uuid4()),
"scope": " ".join(scopes),
}
return jwt.encode(claims, private_key_pem, algorithm="ES256",
headers={"kid": ACTIVE_KID, "typ": "at+jwt"})
def verify_access_token(token: str, jwks_client: jwt.PyJWKClient, audience: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token) # selects by kid
return jwt.decode(token, signing_key.key, algorithms=["ES256"], # pinned list
audience=audience, issuer=ISSUER, leeway=30,
options={"require": ["exp", "iat", "sub", "aud", "iss", "jti"]})In production the private key should not be a PEM file on the issuer's disk: sign through a KMS or HSM so the key never leaves it, and give the issuing service permission only to sign. Note that decode here does not check the typ header; read it with jwt.get_unverified_header and reject anything other than at+jwt if you rely on RFC 9068 typing.
Keys, JWKS and rotation
Verifiers find public keys through a JWKS document, a JSON list of keys each tagged with a kid, usually published at a well-known URL by the issuer. Rotation is a schedule of overlapping windows, worked here for a 10-minute access-token lifetime and verifiers that cache the JWKS for one hour:
- Day 0: generate key B in the KMS and publish it in the JWKS next to the active key A. Keep signing with A.
- Day 0 + 1 hour or more: every verifier's cache now holds B. Switch the issuer to sign with B.
- Switch + 10 minutes or more: the last A-signed token has expired. Wait one more cache period for safety, then remove A from the JWKS.
- Emergency rotation after a leak: remove A immediately and accept that every A-signed token fails. Verifiers must refetch the JWKS when they see an unknown
kid(rate-limited), or new tokens fail until their caches expire.
The order of steps 1 and 2 is the whole trick: publish before you sign, retire after the last token expires. Fetch JWKS only from a configured URL, never from a jku or x5u header in the token itself, which would let an attacker supply their own key.
What every verifier must check
A verifier must, in order: parse the three segments strictly; accept only its pinned algorithms; select the key by kid from its configured JWKS; verify the signature over the received bytes; then check exp and nbf with a small clock-skew allowance, iss exactly, aud against its own identifier, and typ where tokens are typed. Only then may it read the remaining claims. The attacks against each step (algorithm confusion, key injection, missing audience checks) are covered in JWT validation without the bypasses.
Lifetimes, refresh and revocation
A signed token cannot be recalled: it is valid until exp unless every verifier checks a deny-list, which brings back the server-side lookup JWTs were meant to avoid. The practical answer is short-lived access tokens (5 to 15 minutes) plus a refresh token that is opaque, stored server-side, rotated on each use and revocable. Logout or a disabled account then takes effect within one access-token lifetime. Where that window is too long, for example after a password reset, keep a small deny-list of jti values or a per-user "tokens issued before" timestamp and check it at sensitive endpoints only. Where tokens should live in a browser, and when a server session is simply better, is covered in JWT vs session tokens.
Failure modes
- Sensitive data in the payload: tokens appear in logs, browser storage and error trackers, and base64url is not protection.
- HS256 shared across services: any compromised verifier can mint tokens for any user.
- Missing or shared audience: a token stolen from a low-value service works against a high-value one.
- Long-lived access tokens: a leaked 30-day token is a 30-day breach with no off switch.
- Rotation in the wrong order: signing with a key verifiers have not fetched yet causes a burst of 401s that looks like an outage.
- Unbounded JWKS refetching: refetching on every unknown
kidlets an attacker turn garbage tokens into load on your issuer; rate-limit it. - Oversized tokens: stuffing roles into claims pushes headers past proxy limits, giving intermittent 431 or 400 errors.
Trade-offs
JWTs trade revocability for locality: verification needs no network call, but a token stays valid until it expires. They trade size for self-description: a 600-byte header on every request versus a 32-byte session ID and a lookup. Asymmetric signing costs more CPU than HMAC and still verifies in well under a millisecond, which is a good price for not distributing a forging key. Use JWTs where many independent services need to authenticate requests from one issuer; use opaque sessions where one application owns both login and resources.
Further reading on this site: OAuth 2.0 with PKCE and KMS envelope encryption.
What to do next
- Decode one of your production tokens and list every claim; remove anything that is personal data or not needed for an authorization decision.
- Confirm every verifier pins its algorithm list, checks
issandaud, and fetches keys only from a configured JWKS URL. - Move from HS256 to ES256, EdDSA or RS256 if more than one service verifies tokens.
- Put signing keys in a KMS or HSM and add
kidto every token. - Write down the rotation schedule (publish, wait one cache period, switch, wait one token lifetime, retire) and rehearse it in staging.
- Set access tokens to 15 minutes or less and implement rotating, revocable refresh tokens.
- Measure your largest token and keep it well under your proxies' header limits.