Learning Goals
3 minBy the end of this lesson you can:
- Decode a JWT's three parts: header, payload (claims), signature.
- Create and verify JWTs with PyJWT, always checking the signature + expiry.
- Avoid the classic JWT pitfalls:
alg:none, unverified decode, secrets in the payload. - Choose appropriate claims, expiry, and a strong signing key.
Warm-Up · Not Encrypted — Signed
5 mineyJhbGciOiJ... . eyJzdWIiOiJ... . SflKxwRJSM...
HEADER PAYLOAD SIGNATURE
(alg + type) (the claims, e.g. (proves header+payload
user id, role, weren't altered, and
expiry) who issued it)A JWT (JSON Web Token) is a signed bundle of claims — the server-stateless token from Lesson 37. Crucial misconception to kill now: a JWT is signed, not encrypted. Anyone can base64-decode and read the payload — so never put secrets in it. Its security comes entirely from the signature: the server signs with a key only it knows, and verifies that signature on every request. The single most important rule: always verify the signature before trusting a token.
New Concept · Structure, Signing, Validation
14 minThe three parts
HEADER {"alg": "HS256", "typ": "JWT"} ← which signing algorithm
PAYLOAD {"sub": 1, "role": "user", ← the CLAIMS (readable!)
"exp": 1716900000} ← standard: exp, iat, iss, sub
SIGNATURE HMAC-SHA256(base64(header) + "." + ← over header+payload, with the
base64(payload), SECRET_KEY) secret → can't forge without it
Token = base64(header).base64(payload).signatureThe signature is computed over the header and payload using the secret key. Change one character of the payload and the signature no longer matches — so tampering is detected (integrity, Lesson 17). But the payload is just base64, not encryption.
Create & verify with PyJWT
import jwt, os, datetime # pip install pyjwt SECRET = os.environ["JWT_SECRET"] # strong, from env (never hard-coded) # CREATE (on login): sign the claims def make_token(user_id: int, role: str) -> str: payload = { "sub": user_id, "role": role, "iat": datetime.datetime.now(datetime.timezone.utc), "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15), # short expiry } return jwt.encode(payload, SECRET, algorithm="HS256") # VERIFY (on each request): check signature, expiry, algorithm def verify_token(token: str) -> dict: return jwt.decode(token, SECRET, algorithms=["HS256"]) # raises if invalid
jwt.decode with the secret and an explicit algorithms list verifies the signature and the expiry, raising on any problem. That verification is the whole point — never skip it.
⚠️ The classic JWT pitfalls
- Not verifying —
jwt.decode(token, options={"verify_signature": False})or just base64-decoding and trusting the payload. An attacker edits"role":"admin"and you believe it. Always verify. - The
alg: noneattack — historically, some libraries accepted a token with"alg":"none"(no signature) as valid. Always pass an explicitalgorithms=[...]allow-list todecodesonone(and algorithm-confusion) is rejected. - Weak/leaked secret — HS256 security rests entirely on the secret. A weak secret can be brute-forced offline; a leaked one lets anyone forge tokens. Use a long random secret from the environment (Lesson 44).
Claims & expiry hygiene
- Short expiry (
exp) — minutes for access tokens, with a refresh-token flow for longer sessions. This limits the damage of a stolen token (which you can't easily revoke). - Don't put secrets in claims — the payload is readable. User id and role, yes; passwords, API keys, PII, no.
- Verify the issuer/audience (
iss,aud) when relevant, to reject tokens meant for another service. - Revocation is hard — for logout/ban, keep a short expiry plus a server-side denylist of revoked token IDs (
jti) if you need instant revocation.
HS256 vs RS256
HS256 uses one shared secret to sign and verify (simple, fine when the same party does both). RS256 signs with a private key and lets others verify with the public key (Lesson 16) — better when many independent services need to verify but not issue tokens. Don't let an attacker switch your RS256 to HS256 (algorithm confusion) — the explicit algorithms list prevents it.
Worked Example · Issue, Verify, and Why Tampering Fails
12 minGoal: issue a JWT, verify it, then show that editing the payload to escalate privileges fails verification — proving the signature does its job. (And show the dangerous "unverified decode" for contrast.)
import jwt, datetime, base64, json SECRET = "a-long-random-demo-secret-from-env-in-real-life" def make_token(uid, role): return jwt.encode({ "sub": uid, "role": role, "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15), }, SECRET, algorithm="HS256") token = make_token(1, "user") print("issued:", token[:40], "...") # anyone can READ the payload (it's NOT encrypted): payload_b64 = token.split(".")[1] + "==" # pad for base64 print("readable payload:", json.loads(base64.urlsafe_b64decode(payload_b64))) # correct verification: print("verified:", jwt.decode(token, SECRET, algorithms=["HS256"])) # ATTACK: tamper with the payload to claim role=admin, keep old signature import copy header, body, sig = token.split(".") evil_body = base64.urlsafe_b64encode( json.dumps({"sub": 1, "role": "admin"}).encode()).rstrip(b"=").decode() forged = f"{header}.{evil_body}.{sig}" # signature no longer matches body try: jwt.decode(forged, SECRET, algorithms=["HS256"]) except jwt.InvalidSignatureError: print("forged token REJECTED — signature doesn't match ✓") # the DANGEROUS anti-pattern (NEVER do this): print("unverified (DANGER):", jwt.decode(forged, options={"verify_signature": False})) # trusts role=admin!
issued: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ...
readable payload: {'sub': 1, 'role': 'user', 'exp': 1716900900}
verified: {'sub': 1, 'role': 'user', 'exp': 1716900900}
forged token REJECTED — signature doesn't match ✓
unverified (DANGER): {'sub': 1, 'role': 'admin'} ← privilege escalation if you skip verify!Read the code
Three lessons in one demo. First, the payload is plainly readable (base64, not encryption) — so it must never hold secrets. Second, the attacker's attempt to change role to admin is rejected, because editing the payload invalidates the signature they can't recompute without the secret — that's the integrity guarantee working. Third, the "DANGER" line shows the catastrophe of skipping verification: verify_signature=False happily returns role: admin from the forged token. The rule is absolute: always verify, with an explicit algorithms list.
Try It Yourself
13 minCreate a JWT with PyJWT, then base64-decode its payload yourself to read the claims. Confirm you can read them without the secret — proving it's not encrypted, only signed.
Edit a token's payload and confirm jwt.decode raises InvalidSignatureError. Then issue a token with a 1-second expiry, wait, and confirm jwt.decode raises ExpiredSignatureError.
Hint
import jwt, time, datetime t = jwt.encode({"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=1)}, SECRET, algorithm="HS256") time.sleep(2) try: jwt.decode(t, SECRET, algorithms=["HS256"]) except jwt.ExpiredSignatureError: print("expired — rejected ✓")
Demonstrate why you must pass algorithms=["HS256"]: craft a token with alg:none and show that decoding with an explicit allow-list rejects it (whereas a careless decode might accept it). Explain the historical vulnerability.
Hint
# A safe decode ALWAYS specifies allowed algorithms: jwt.decode(token, SECRET, algorithms=["HS256"]) # 'none' not allowed → rejected # The alg:none attack worked when libraries trusted the token's own # 'alg' header and accepted 'none' (no signature). Pinning algorithms # server-side defeats it (and RS256→HS256 algorithm confusion).
Mini-Challenge · A Token Auth Middleware
8 minBuild a Flask decorator @require_token that extracts a Bearer token, verifies it correctly (signature + expiry + explicit algorithm), attaches the claims to the request, and returns 401 on any failure — with a separate path for expired vs. invalid. This is real API auth middleware.
Show a sample solution
import jwt, os, functools from flask import request, g, jsonify SECRET = os.environ["JWT_SECRET"] def require_token(fn): @functools.wraps(fn) def wrapper(*a, **k): auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return jsonify(error="missing token"), 401 token = auth[7:] try: claims = jwt.decode(token, SECRET, algorithms=["HS256"]) # ✓ verify except jwt.ExpiredSignatureError: return jsonify(error="token expired"), 401 except jwt.InvalidTokenError: return jsonify(error="invalid token"), 401 g.user = claims # claims available to the view return fn(*a, **k) return wrapper # usage: # @app.get("/api/me") # @require_token # def me(): return jsonify(user=g.user["sub"], role=g.user["role"])
Non-negotiables: Bearer extraction, verified decode with explicit algorithm, expired-vs-invalid handling, claims attached, 401 on failure.
Recap
3 minA JWT is three base64 parts — header, payload (claims), signature — joined by dots. It's signed, not encrypted: the payload is readable, so never put secrets in it; security comes from the signature, which detects tampering. Create with jwt.encode, and always verify with jwt.decode(token, secret, algorithms=[...]) — the explicit algorithm list defeats the alg:none and algorithm-confusion attacks, and exp is checked automatically. Never skip verification (it lets attackers forge role:admin). Use short expiry + refresh for revocation, a strong secret from the environment, and consider RS256 when many services must verify but not issue.
Vocabulary Card
- JWT
- A signed (not encrypted) token of claims: header.payload.signature.
- claims
- The payload data (sub, role, exp, iat) — readable, so no secrets.
- alg:none attack
- Forging an unsigned token; defeated by pinning allowed algorithms.
- verify
- Checking signature + expiry before trusting a token — never skip it.
Homework
4 minBuild a JWT issue/verify pair and the @require_token middleware. Demonstrate three rejections: tampered payload, expired token, and alg:none. Write a paragraph explaining to a teammate why a JWT must never hold secrets, why you must always verify with an explicit algorithm, and how you'd handle logout/revocation given JWTs are stateless.
Sample · JWT explainer
Never put secrets in a JWT: the payload is just base64 — ANYONE who has the token can decode and read it (no key needed). It's signed, not encrypted. So sub/role/exp are fine; passwords, API keys, PII are not. (If you truly need encrypted claims, that's JWE, not a normal JWT.) Always verify with an explicit algorithm: jwt.decode(token, secret, algorithms=["HS256"]). This (a) checks the signature so a forged role:admin is rejected, (b) checks exp, and (c) pins the algorithm so the alg:none trick and RS256→HS256 confusion are refused. Skipping verification = trusting attacker-controlled data. Logout/revocation with stateless JWTs: keep access tokens SHORT-lived (e.g. 15 min) so a stolen one expires fast, use refresh tokens for longer sessions, and for instant revocation (ban/logout) keep a server-side denylist of revoked token IDs (jti) checked on each request. Demo'd 3 rejections: tampered (InvalidSignatureError), expired (ExpiredSignatureError), alg:none (rejected by the allow-list).
Non-negotiables: working issue/verify + middleware, three demonstrated rejections, and the no-secrets / always-verify / revocation explanation.