JWT & Bearer Tokens
1 · The lesson
readThe examples use Flask or FastAPI. Run them locally with the framework installed, because auth flows need a running server. Expected output is shown in comments.
A JWT (JSON Web Token) is a compact, URL-safe, signed string that carries claims about a user. The server signs it on login, and the client sends it back on every request. The server verifies the signature and trusts the claims without looking anything up in a database. That is the main benefit: stateless authentication that scales across many servers without a shared session store.
JWT is also very often used incorrectly. Common problems are algorithm-confusion CVEs, payloads that developers think are encrypted, and tokens that can't be revoked. This lesson shows how to use JWT correctly and, just as important, when to use session cookies instead.
1. The Structure: Three Base64 Parts
A JWT is header.payload.signature: three base64url-encoded strings joined by dots.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcxNTY4ODAwMH0.abc123_signature_bytes └──────────── header ────────────┘ └─────── payload ───────┘ └── signature ──┘
Decoded:
// header
{"alg": "HS256", "typ": "JWT"}
// payload (claims)
{"sub": "42", "exp": 1715688000}
// signature
HMAC_SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)The header and payload are base64-encoded, not encrypted. Anyone with the token can decode and read them. The signature guarantees they haven't been changed: if you change one byte of the payload, the signature no longer matches, and the server rejects the token.
The most important point in this lesson: JWT payloads are signed, not encrypted. Treat them as public. Never put a password, an API key, or anything you wouldn't print on a postcard inside one.
2. Why JWT, and Why Not
Choose JWT when:
- You have multiple backend services and a shared secret/public key. Any service can validate the token without a DB call.
- You want stateless auth that horizontally scales without a shared session store.
- You're building a public API where clients send the token on every request.
Skip JWT when:
- It's a single web app with cookies. Server-side sessions (covered in auth-passwords) are simpler, revocable, and don't need complicated token rotation.
- You need immediate revocation. JWTs are valid until they expire, so "log this user out everywhere right now" is hard without a denylist (and a denylist removes the stateless benefit).
- You don't have a real reason. "Because it's modern" is not one.
In short: JWTs solved the cross-service auth problem, but they did not improve login for a single app. A lot of real-world JWT code does what session cookies would do, but with more steps and more security risks.
3. Standard Claims: The Three-Letter Names
RFC 7519 defines a set of standard claims. Use them, because libraries already validate them automatically.
| Claim | Meaning | Example |
|---|---|---|
sub | Subject: who the token is about. | User ID. |
iss | Issuer: who created the token. | "https://auth.myapp.com" |
aud | Audience: who is allowed to accept it. | "https://api.myapp.com" |
exp | Expiry: Unix timestamp after which it's invalid. | 1715688000 |
iat | Issued at: Unix timestamp when it was created. | 1715684400 |
nbf | Not before: invalid until this timestamp. | 1715684400 |
jti | JWT ID: unique ID for the token (for denylisting). | UUID |
You can add custom claims freely ("role": "admin", "email": "..."), but remember that anyone with the token can read them.
4. Signing: HS256 vs RS256
Two algorithm families cover almost all real JWT usage.
HS256: HMAC with a shared secret.
- One key, used to both sign and verify.
- Simple and fast. Right for single-service apps and tightly-coupled services that already share secrets.
- Risk: every service that can verify tokens can also forge them. Never give the key to clients.
RS256: RSA with a public/private key pair.
- Auth server signs with the private key, and everyone else verifies with the public key.
- Right for federated/multi-service setups where you don't want every downstream service to be able to create tokens.
- The public key is published (sometimes via a JWKS endpoint), the private key is held by the issuer only.
There are others (ES256, which uses elliptic curves, and EdDSA, which is newer and faster), but the standard choice is HS256 for monoliths and RS256 for federated systems.
5. HS256 in Practice with PyJWT
pip install pyjwt
import jwt import time SECRET = "long-random-string-from-environment" # at least 32 bytes, from an env var in real code payload = { "sub": "user-42", "iat": int(time.time()), "exp": int(time.time()) + 15 * 60, # 15 minutes "role": "user", } token = jwt.encode(payload, SECRET, algorithm="HS256") print(token) # eyJhbGciOi... # Anyone with the secret can verify and decode decoded = jwt.decode(token, SECRET, algorithms=["HS256"]) print(decoded) # {'sub': 'user-42', 'iat': ..., 'exp': ..., 'role': 'user'}
Two details you must always get right:
algorithms=["HS256"]is a list, and you must pass it. The originalpyjwt.decode(token, key)API let the token's own header choose the algorithm, including"alg": "none", and old libraries would accept it. Letting the token choose its algorithm makes the"none"attack and the algorithm-confusion attack (Common Mistake 6) possible. Always pin the algorithm explicitly.- The secret must be long and random. A short or human-chosen secret can be brute-forced offline. 32+ bytes from
secrets.token_urlsafe(32), stored in the environment (see envconfig).
Catch the error types you expect:
import jwt try: claims = jwt.decode(token, SECRET, algorithms=["HS256"]) except jwt.ExpiredSignatureError: # token was valid but its exp has passed raise Unauthorised("token expired") except jwt.InvalidTokenError: # bad signature, malformed token, wrong audience, and so on raise Unauthorised("invalid token")
setup added so this can run · defines token, SECRET, Unauthorised
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) token = _AutoMock('token') SECRET = _AutoMock('SECRET') def Unauthorised(*_a, **_kw): print('-> Unauthorised() called') return _AutoMock('Unauthorised()')
InvalidTokenError is the base class for everything else (InvalidSignatureError, DecodeError, InvalidAudienceError, …). Catching ExpiredSignatureError first and then InvalidTokenError covers the cases you will meet in practice.
6. Short Expiry + Refresh Tokens
The longer a token lives, the longer a stolen one is useful, and the harder it is to revoke. So make access tokens short-lived (15 minutes is typical), and pair them with a long-lived refresh token that the client exchanges for a new access token.
POST /login → access_token (15 min) + refresh_token (30 days)
GET /api/things → Authorization: Bearer <access_token>
(access valid → 200 OK)
[15 minutes later]
GET /api/things → 401 (access expired)
POST /auth/refresh → access_token (new, 15 min) + refresh_token (rotated)
GET /api/things → 200 OKThe refresh token:
- Is stored more carefully than the access token. Ideally it is stored server-side: keep a row per token, link it to a user and a device, and mark it used/revoked.
- Should rotate on every use, so each refresh token works only once. If the same refresh token is presented twice, treat it as theft: revoke the whole chain of tokens and force a new login.
- Has a long but finite lifetime (30-90 days). Tokens that never expire are a security risk.
The access token stays a JWT. The refresh token can be a JWT, but most production systems use an opaque random string backed by a server-side store. Revocation matters most for the long-lived token, and revoking a stateless JWT is hard.
7. Where Does the Client Store the Token?
There is no perfect option:
| Storage | Reads it | Attack vector |
|---|---|---|
localStorage | JS code | XSS: any script on the page can steal it. |
HttpOnly cookie | Browser only, on requests | CSRF: cross-site requests carry it. |
| Memory (JS variable) | JS code, lost on refresh | XSS, but lifetime is tiny. |
In 2026, the usual pattern for SPAs combines these options:
- Access token (short-lived, 15 min): in JS memory only. Lost on refresh, but the refresh flow restores it.
- Refresh token:
HttpOnly+Secure+SameSite=Strictcookie. Browser sends it only on/auth/refresh(path-scoped). XSS can't read it, and SameSite=Strict blocks CSRF. - CSRF token for any state-changing request: standard double-submit-cookie or synchroniser-token pattern (see security-checklist §CSRF).
The "just localStorage" approach is quick to build, but one XSS bug lets an attacker steal the tokens of every user who loads the page.
8. Validation: What You Must Check
jwt.decode checks the signature and exp by default. You must ask for the other checks explicitly:
claims = jwt.decode( token, SECRET, algorithms=["HS256"], audience="https://api.myapp.com", # validates `aud` claim issuer="https://auth.myapp.com", # validates `iss` claim leeway=10, # allow 10 seconds of clock difference options={"require": ["exp", "iat", "sub"]}, # reject tokens missing these )
setup added so this can run · defines token, SECRET, jwt
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) token = _AutoMock('token') SECRET = _AutoMock('SECRET') jwt = _AutoMock('jwt')
The full validation checklist:
- Signature: automatic, if you pass a correct
algorithms=list. exp: automatic.nbf: automatic if present.aud: if the token has anaudclaim,decoderejects it unless you pass a matchingaudience=. If you passaudience=, a token with noaudclaim is rejected too. Multi-tenant systems must check this. Otherwise a token for service A can be replayed against service B.iss: passissuer=when you accept tokens from multiple issuers and need to know which.- Required claims:
options={"require": [...]}rejects tokens that are missing them, so a malformed token is never accepted as valid with unexpected default values.
9. Revocation: The Hardest Part
A signed JWT is valid until exp. There's no "log out" button that makes it invalid by itself. There are three options, from least to most disruptive:
1. Short expiry: the cheap defence.
If access tokens live 15 minutes, the window of stolen-token usefulness is ~15 minutes. For most apps that's enough.
2. Denylist: the heavy option.
On logout, store jti (or a hash of the token) in Redis with a TTL of the remaining exp - now. Every validating service checks the denylist. Cost: an extra Redis read per request, and you've brought back the server-side state that JWT was supposed to remove.
3. Rotate the signing key: the last resort.
Change SECRET, which invalidates every token everywhere. Use this when you suspect the key itself is compromised. Every legitimate user is logged out too.
A practical setup for most apps:
- 15-min access tokens (no denylist).
- Server-side refresh tokens (revocable in a DB row).
- A
password_changed_attimestamp on the user row. Reject any access token whoseiatpredates it.
The last item lets you "log this user out everywhere on password change" without a per-token denylist.
10. A Full FastAPI JWT Auth Flow
Here is everything together: login, a protected route, and all the right validation:
import os import time from datetime import datetime, timedelta, timezone import jwt from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm SECRET = os.environ["JWT_SECRET"] # 32+ bytes, from an env var ALGO = "HS256" ACCESS_TTL = timedelta(minutes=15) app = FastAPI() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login") def issue_access_token(user_id: str) -> str: now = datetime.now(timezone.utc) return jwt.encode( { "sub": user_id, "iat": int(now.timestamp()), "exp": int((now + ACCESS_TTL).timestamp()), "iss": "https://auth.myapp.com", }, SECRET, algorithm=ALGO, ) def current_user(token: str = Depends(oauth2_scheme)) -> str: """FastAPI dependency that validates a bearer token and returns the user_id.""" try: claims = jwt.decode( token, SECRET, algorithms=[ALGO], issuer="https://auth.myapp.com", options={"require": ["exp", "iat", "sub"]}, ) except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="invalid token") return claims["sub"] @app.post("/login") def login(form: OAuth2PasswordRequestForm = Depends()): user = authenticate(form.username, form.password) # uses PasswordService from the previous lesson if user is None: raise HTTPException(status_code=401, detail="invalid credentials") return {"access_token": issue_access_token(str(user.id)), "token_type": "bearer"} # sub must be a string @app.get("/me") def whoami(user_id: str = Depends(current_user)): return {"user_id": user_id}
setup added so this can run · defines authenticate
import os # noqa: F401 os.environ.setdefault("JWT_SECRET", "example-jwt-secret") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def authenticate(*_a, **_kw): print('-> authenticate() called') return _AutoMock('authenticate()')
How it works: OAuth2PasswordBearer reads Authorization: Bearer ..., and the current_user dependency decodes and validates the token. Every route that needs auth declares Depends(current_user) and receives the user ID with no extra code.
11. Common Mistakes
1. algorithms=None or algorithms=[]. This is how algorithm-confusion CVEs happen. Some old PyJWT examples accept {"alg": "none"} because they didn't pin the algorithm. Always pass the explicit list of algorithms you accept.
2. Treating the payload as encrypted. It's base64, not AES. Anyone with the token can decode it. Don't put passwords, full names if you can avoid it, or anything you wouldn't want on a postcard. Use JWE (JSON Web Encryption) if you need encryption. But you almost never do: usually you just need fewer claims.
3. Long-lived access tokens with no refresh. "We use JWT, exp=30 days" means a stolen token works for 30 days. Use short access tokens + refresh tokens, or accept the trade-off on purpose.
4. Storing tokens in localStorage. XSS-vulnerable. Use HttpOnly cookies for the refresh token, and keep the access token in memory only.
5. Forgetting exp. A JWT without exp is valid forever. Always set one.
6. Verifying with the wrong key type. HS256 takes a shared secret string, and RS256 takes a public key PEM. If you mix them up, decode can do something dangerous: older PyJWT versions treated an RSA public key as an HMAC secret, so anyone who knew the public key could forge tokens. Modern PyJWT refuses, but the rule is the same: pin the algorithm and pass the right key type.
7. Trusting sub without checking it's still valid. A token says sub=42, but user 42 was deleted yesterday. The token is still cryptographically valid, but its information is out of date. Look up the user, or accept that 15-min staleness is OK.
8. Not validating aud/iss in a multi-service system. Service A's token gets replayed against service B. Always set and check audience/issuer.
🎯 Your Turn: issue_token and verify_token
Write two functions:
1. issue_token(user_id: str, secret: str, ttl_minutes: int = 15) -> str: HS256, includes sub, iat, exp.
2. verify_token(token: str, secret: str) -> dict | None: returns the claims dict on success, None on expired or invalid. Pins algorithms=["HS256"]. Requires exp and sub to be present.
Skeleton:
import time import jwt ALGO = "HS256" def issue_token(user_id: str, secret: str, ttl_minutes: int = 15) -> str: # TODO 1: build payload with sub, iat, exp # TODO 2: return jwt.encode(...) ... def verify_token(token: str, secret: str) -> dict | None: # TODO 1: jwt.decode with algorithms=["HS256"] # TODO 2: require exp and sub via options={"require": [...]} # TODO 3: return claims on success, None on expired or invalid ...
Hint 1: Timestamps as ints
Theiat and exp claims should be Unix timestamps in seconds, as integers. int(time.time()) for now, and int(time.time() + ttl_minutes * 60) for the expiry. PyJWT does accept datetime objects too, but ints make the intent clear and avoid timezone mistakes.
Hint 2: Catch InvalidTokenError as the base class
jwt.ExpiredSignatureError is a subclass of jwt.InvalidTokenError. A single except jwt.InvalidTokenError covers both. The exercise asks for the same return value in either case (None), so one except is enough. In a real app, distinguish them so the UI can say "session expired" vs "invalid session".
Show full solution
import time import jwt ALGO = "HS256" def issue_token(user_id: str, secret: str, ttl_minutes: int = 15) -> str: """Create a short-lived HS256 access token for `user_id`.""" if not user_id: raise ValueError("user_id is required") if not secret or len(secret) < 32: # Catches the most common mistake: a 6-char "secret" copied from an example raise ValueError("secret must be at least 32 chars") now = int(time.time()) payload = { "sub": user_id, "iat": now, "exp": now + ttl_minutes * 60, } return jwt.encode(payload, secret, algorithm=ALGO) def verify_token(token: str, secret: str) -> dict | None: """Validate a token. Returns claims dict on success, None on any failure.""" try: return jwt.decode( token, secret, algorithms=[ALGO], # fixed list, never trust the alg in the header options={"require": ["exp", "sub", "iat"]}, ) except jwt.InvalidTokenError: # Covers ExpiredSignatureError, InvalidSignatureError, DecodeError, # MissingRequiredClaimError, and the other token errors. return None # Demo SECRET = "this-is-a-long-random-secret-from-environment" token = issue_token("user-42", SECRET, ttl_minutes=15) print("token:", token[:30] + "...") claims = verify_token(token, SECRET) print("claims:", claims) # {'sub': 'user-42', 'iat': 1715684400, 'exp': 1715685300} # A tampered signature gives None tampered = token[:-4] + "AAAA" print("tampered:", verify_token(tampered, SECRET)) # None # A wrong secret gives None print("wrong secret:", verify_token(token, "different-secret-also-32-chars-long")) # None # An expired token gives None expired = issue_token("user-42", SECRET, ttl_minutes=-1) # already expired print("expired:", verify_token(expired, SECRET)) # None
What you built:
- Pinned algorithm:
algorithms=["HS256"]. This is the most important line. Code that passedalgorithms=Noneand so accepted"alg": "none"tokens has shipped in real production systems. - Required claims declared up front:
options={"require": [...]}. A token withoutexpwould otherwise be valid forever, and this rejects it. - One
exceptfor all errors:jwt.InvalidTokenErroris the base class. Every common failure (bad signature, expired, malformed, missing claims) is caught by it. - Secret length check: fails immediately at issuance if the configured secret is too short. This helps in development, where someone might pass
"secret"and not notice that it can be brute-forced.
What's intentionally missing, and would be the next steps in production:
audandissclaims, withaudience=/issuer=on decode, when you have multiple services.- A separate refresh token and a
/refreshendpoint. The access token's job is to be short-lived, and the refresh token's job is to outlive it and be revocable server-side. - A
jticlaim + denylist for the rare case you need immediate revocation. - An
iat-vs-password_changed_atcheck in the validation pipeline.
But these two functions are the core. Every JWT auth system builds on them and adds more checks.
What You Learned
- A JWT is
header.payload.signature: base64-encoded JSON parts plus a signature. Signed, not encrypted. Treat the payload as public. - Pin the algorithm. Always
algorithms=["HS256"](or whichever). Letting the token's own header pick the algorithm leads to the algorithm-confusion CVE. - HS256 for monoliths and tightly-coupled services, RS256 for federated multi-issuer setups.
- Standard claims:
sub,iss,aud,exp,iat,nbf,jti.expis mandatory. Addaudandissfor multi-service systems. - Short access tokens (15 min) + long refresh tokens (30 days, server-side, rotating). Don't ship 30-day access tokens.
- Client storage: refresh token in
HttpOnly+Secure+SameSite=Strictcookie, and access token in JS memory only. NeverlocalStoragefor the long-lived one. - Validation: signature,
exp,aud(if relevant),iss(if relevant), required claims.jwt.InvalidTokenErroris the base exception. - Revocation is the hard part. Short expiry handles most cases. Denylists work but remove the stateless benefit.
password_changed_atis a cheap way to "log out everywhere".
Next: OAuth2 with Real Providers: how "Sign in with Google" works, the state parameter that prevents CSRF on the callback, and why OAuth is authorisation, not authentication.
Found this useful? Share it with a friend who’s learning Python.