In 01-02 you learned to recognize the eyJ prefix as the fingerprint of Base64-encoded JSON, and we discovered that MediNube's portal session token was exactly that: encoded JSON, unsigned, that anyone could edit. We promised to come back once we had the tools to fix it, and that day is today: we have HMAC (03-02), asymmetric signatures (04-03), and a key manager (06-01). In this lesson MediNube settles its oldest debt: we demonstrate the attack against the legacy token, understand what a real JWT is, learn to use one with PyJWT while dodging its classic traps (alg: none, algorithm confusion, unvalidated claims), and build medinube/sessions.py, the portal's definitive session module.
Contents
- The 01-02 debt: the token anyone could edit
- Stateful sessions vs. self-contained tokens
- Anatomy of a JWT: header, payload, signature
- Signing and verifying with PyJWT: HS256 and EdDSA
- Classic attacks against JWT, and their defense
- Short expiration and refresh tokens
- Revocation: the price of being self-contained
- Where to store the token in the browser
medinube/sessions.py: the final implementation
The 01-02 debt: the token anyone could edit
Here's the legacy code MediNube's portal has been using for sessions — our oldest _BAD:
# medinube/sessions_legacy.py — DO NOT USE
import base64, json
def create_session_BAD(username: str, role: str) -> str:
data = {"username": username, "role": role}
return base64.urlsafe_b64encode(json.dumps(data).encode()).decode()
def read_session_BAD(token: str) -> dict:
return json.loads(base64.urlsafe_b64decode(token))The attack requires neither cryptanalysis nor tools: Ana Pérez (or MalloryClinic, with Ana's account) opens the browser console and does this:
>>> token = create_session_BAD("ana.perez", "patient")
>>> token
'eyJ1c2VybmFtZSI6ICJhbmEucGVyZXoiLCAicm9sZSI6ICJwYXRpZW50In0='
# The "attacker" decodes, edits, and re-encodes:
>>> data = json.loads(base64.urlsafe_b64decode(token))
>>> data["role"] = "doctor" # ← instant privilege escalation
>>> forged = base64.urlsafe_b64encode(json.dumps(data).encode()).decode()
>>> read_session_BAD(forged)
{'username': 'ana.perez', 'role': 'doctor'} # the server believes itGolden rule 4, in its most expensive version: encoding isn't encrypting... and it isn't authenticating either. Base64 is a change of alphabet, not a protection. The server is trusting data the client fabricated. The fix isn't hiding the content (the user is allowed to know their own role); it's letting the server verify that it issued the token and nobody touched it — a signature. That's exactly what a JWT is.
Stateful sessions vs. self-contained tokens
Before rushing toward JWT, an engineer's honesty: the classic alternative is still excellent. A stateful session keeps the data on the server and hands the browser only an opaque random identifier (a cookie with 128+ CSPRNG bits, rules 3 and 5). A self-contained token (JWT) carries the data with it, signed.
| Aspect | Stateful session (cookie + table) | Self-contained token (JWT) |
|---|---|---|
| What the server stores | One row per session | Nothing (just the signing key) |
| Revocation | Trivial: DELETE FROM sessions |
Hard (we'll see: jti + list) |
| Verification | A DB/Redis lookup per request | Cryptography only, no I/O |
| Ideal scenario | A monolithic app with its DB nearby | Microservices, APIs, third parties that verify |
| Visible content | No (the id is opaque) | Yes: the payload is readable by design |
| Expiration | Decided by the server, live | Baked into the token at issuance |
The table says something uncomfortable for the hype: if your app is a monolith with a DB a millisecond away, the classic session cookie is simpler and more secure (instant revocation). JWT wins when verification has to happen far from the issuer: across MediNube's microservices without touching the DB on every hop, or when Robles Pharmacy needs to verify a token MediNube issued without calling us. That's our actual use case, so let's go.
Anatomy of a JWT: header, payload, signature
A JWT (JSON Web Token, RFC 7519) is three Base64URL blocks separated by dots: header.payload.signature. Since both the header and payload are JSON, an authentic JWT shows off... two or three eyJs! Let's decode one by hand to strip it of all mystery:
import base64, json
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiJhbmEucGVyZXoiLCJyb2wiOiJwYWNpZW50ZSIsImlzcyI6Imh0dHBzOi8v"
"YXBpLm1lZGludWJlLmV4YW1wbGUiLCJleHAiOjE3ODM1MDQwMDB9."
"4v0X9K1v2xLQnO7Yd3rZ8mPqW5tJc6hB0aFgN_sUwEo")
def b64url_decode(part: str) -> bytes:
# Base64URL omits padding '='; you have to restore it to decode
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))
header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'ana.perez', 'rol': 'paciente', ...}
# signature: raw bytes, NOT JSON — it's HMAC-SHA256(key, header + "." + payload)Notice: anyone can read the header and payload (it's just Base64URL). The signature doesn't hide anything; it guarantees integrity and origin — like GCM's tag in 02-03 or the prescription signatures in 04-03. Never put secrets in a JWT payload.
The standard claims we'll always use:
| Claim | Meaning | Why it matters |
|---|---|---|
sub |
Subject: who the token is about (ana.perez) |
Identity |
exp |
Expiration: when it stops being valid (Unix timestamp) | Without it, a stolen token is valid forever |
iat |
Issued at: when it was issued | Auditing; lets you invalidate "everything before" |
iss |
Issuer: who issued it (https://api.medinube.example) |
Stops you from accepting tokens from another issuer |
aud |
Audience: who it's for (farmacia-robles) |
Stops a token from one service being reused on another |
jti |
JWT id: the token's unique identifier | The piece that makes revocation possible |
Signing and verifying with PyJWT: HS256 and EdDSA
PyJWT (pip install pyjwt[crypto]) does the work. The first decision is the algorithm, and you already know the two candidates:
- HS256 = HMAC-SHA256 (03-02): symmetric. Whoever can verify can also issue. Perfect between MediNube's internal services, which share the key (from 06-01's manager).
- RS256 (careful, not RSA-PSS: RS256 is PKCS#1 v1.5; PS256 is PSS) and EdDSA (Ed25519, 04-03): asymmetric. You sign with the private key and verify with the public one. Essential when third parties verify: Robles Pharmacy verifies with our public key without being able to issue tokens — with HS256 we'd have to hand it the key, and it could then forge MediNube tokens.
import jwt, time
# --- Internal (HS256): between MediNube's own services ---
HS_KEY = get_from_secrets_manager("medinube/production/jwt-internal") # 32 CSPRNG bytes
token = jwt.encode(
{"sub": "servicio-recetas", "iss": "https://api.medinube.example",
"aud": "servicio-historiales", "iat": int(time.time()),
"exp": int(time.time()) + 300}, # 5 minutes: internal = short
HS_KEY, algorithm="HS256",
)
# --- External (EdDSA): Robles Pharmacy verifies without being able to issue ---
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = load_private_key_from_manager("medinube/production/jwt-signing-ed25519")
pharmacy_token = jwt.encode(
{"sub": "receta-8842", "iss": "https://api.medinube.example",
"aud": "farmacia-robles", "exp": int(time.time()) + 900},
private_key, algorithm="EdDSA",
)
# Robles Pharmacy only needs the PUBLIC key to verify:
data = jwt.decode(pharmacy_token, medinube_public_key, algorithms=["EdDSA"],
audience="farmacia-robles", issuer="https://api.medinube.example")Reasoning behind the decisions: keys come from the manager (06-01), never from the code; the internal exp is measured in minutes (internal tokens are requested and thrown away); and we picked EdDSA over RS256 for the same reasons as 04-03 — small keys and signatures, no padding decisions to get wrong, excellent performance.
Classic attacks against JWT, and their defense
JWT's history is full of broken implementations. The three attacks you need to know all hit the same weak spot: letting the token decide how it gets verified.
alg: none: the original spec allowed{"alg": "none"}= "unsigned." Old libraries accepted that header... written by the attacker: you'd craft a token with whatever payload you wanted,alg: none, an empty signature, and you were in. It'screate_session_BADwearing a standard's costume.- RS256 → HS256 confusion: the attacker takes a legitimate RS256 token, changes the header to HS256, and signs with HMAC using... the public key (which is public) as the HMAC key. A naive library that receives "the key" as a blob and obeys the token's
algwill verify the HMAC against the public key — and pass! The public key was meant for verifying RSA, not for being a symmetric secret. - Unvalidated claims: signing correctly and then not checking
exp(tokens that never expire),aud(a token issued for Robles Pharmacy reused against the medical-records API), oriss(accepting tokens from a different issuer whose key happens to be known).
The defense with modern PyJWT is a single discipline: the verifier sets its own expectations; the token doesn't get a say.
def verify_BAD(token):
# NEVER: no algorithms=, so PyJWT falls back to trusting the token's own
# alg on old versions; no audience, no issuer... the token is in charge.
return jwt.decode(token, KEY, options={"verify_signature": False}) # ← horror
def verify_correctly(token: str, public_key) -> dict:
return jwt.decode(
token,
public_key,
algorithms=["EdDSA"], # CLOSED list: kills 'none' and the confusion attack
audience="farmacia-robles", # this token is FOR me
issuer="https://api.medinube.example", # and issued by who I expect
options={"require": ["exp", "iss", "aud", "sub"]}, # mandatory claims
leeway=30, # 30s of clock-skew tolerance
)Point by point: algorithms=["EdDSA"] is mandatory, always — being a closed list you chose, alg: none and the switch to HS256 both die right here (PyJWT also refuses to use an asymmetric public key with HMAC algorithms, defense in depth); audience and issuer close off reuse between services; require turns a missing claim into an error instead of "not checked"; leeway avoids false rejections from slightly out-of-sync clocks. Any failure raises an exception (jwt.InvalidSignatureError, jwt.ExpiredSignatureError...): catch them and return a 401, without leaking details to the client.
Short expiration and refresh tokens
If a stolen JWT is valid until its exp, the first mitigation is obvious: short exp (minutes). But nobody wants to log in again every 15 minutes. The standard solution is the access + refresh pair:
sequenceDiagram
participant N as Browser (Ana)
participant A as api.medinube.example
participant BD as DB (refresh table)
N->>A: login (username + password → Argon2id, 03-03)
A->>BD: stores sha256(refresh)
A-->>N: access JWT (15 min) + refresh token (7 days)
Note over N,A: ...15 minutes go by, access expires...
N->>A: POST /token/refresh (refresh token)
A->>BD: does sha256(refresh) exist and is unused/unrevoked?
A->>BD: marks the old one as used, stores sha256(new one)
A-->>N: new access + NEW refresh (rotation)
The design's pieces, all familiar:
- The access token is the JWT: self-contained, verifiable with no DB, a 15-minute lifespan.
- The refresh token doesn't need to be a JWT: it's an opaque CSPRNG secret (
secrets.token_urlsafe(32)) that does live in the DB... stored assha256(token), exactly the pattern of 03-03's recovery tokens: a DB dump doesn't hand out sessions for free. - Rotation with theft detection: every use of the refresh token issues a new one and invalidates the previous one. If someone presents an already-used refresh token, there are two holders → the entire family gets revoked, and Ana has to log in again.
Revocation: the price of being self-contained
"Log out everywhere," "this employee no longer works here," "a token leaked": with DB sessions it's a DELETE; with JWT, the signed token is still cryptographically valid until its exp. There's no way to "unsign" a signature. Options, from least to most state:
- Short TTLs as the primary mitigation: with a 15-minute access token, a stolen token's window is 15 minutes, and the real revocation happens at the refresh step (which is in the DB). It's the exact same lesson as certificates' short lifetimes in 05-03: when revoking is hard, expire fast.
- A revocation list by
jti: a table/Redis with revokedjtis until theirexp. The verifier checks the list... and you've just reintroduced a lookup per request — the honest hybrid: only sensitive endpoints check it. - Per-user invalidation: store a
not_valid_beforetimestamp per user; any token with an earlieriatdies. Cheap (one value per user, cacheable) and covers "log out all my sessions."
MediNube uses 1 + 3: a 15-minute access token and a per-user marker for the panic button.
Quick mention to complete the map: besides JWS (signed, which is what we've done), there's JWE (JSON Web Encryption): the token is encrypted, not just signed, for payloads the client isn't supposed to read. Five parts instead of three, same standards family (JOSE). MediNube doesn't need it — we put nothing secret in the payload — but you should recognize it when you see it.
Where to store the token in the browser
Brief and practical, because this is where many battles won by cryptography get lost:
localStorage: accessible from JavaScript → any XSS exfiltrates the token. Avoid it for session tokens.- A cookie with
HttpOnly; Secure; SameSite=Lax(orStrict): JavaScript can't read it (HttpOnlyneutralizes theft via XSS), it only travels over HTTPS (Secure, backed by 05-02's HSTS), andSameSitecuts off most CSRF. It's the correct default for MediNube's portal.
A JWT inside an HttpOnly cookie combines the best of both worlds: stateless server-side verification, XSS-based theft blocked on the client. Golden rule 9: cryptography doesn't replace the rest of security — a perfectly signed token in localStorage is still a token stolen via an XSS.
medinube/sessions.py: the final implementation
All together: EdDSA, complete claims, closed expectations, and a kid (key id) in the header so the signing key can rotate without invalidating live sessions — the same move as the 0x01/0x02 byte for medical records, now applied to JWT:
# medinube/sessions.py — portal sessions, final version
import secrets, time, jwt
ISS = "https://portal.medinube.example"
AUD = "portal"
TTL_ACCESS = 15 * 60
# Signing keys, versioned by kid, served by the manager (06-01).
# "2026-07" signs; "2026-01" only verifies until its last token dies.
KEYS = {
"2026-07": load_ed25519_keypair("medinube/production/jwt-portal/2026-07"),
"2026-01": load_ed25519_keypair("medinube/production/jwt-portal/2026-01"),
}
ACTIVE_KID = "2026-07"
def issue(username: str, role: str) -> str:
now = int(time.time())
return jwt.encode(
{"sub": username, "rol": role, "iss": ISS, "aud": AUD,
"iat": now, "exp": now + TTL_ACCESS, "jti": secrets.token_hex(16)},
KEYS[ACTIVE_KID].private,
algorithm="EdDSA",
headers={"kid": ACTIVE_KID}, # ← which key verifies this token
)
def verify(token: str) -> dict:
kid = jwt.get_unverified_header(token).get("kid")
if kid not in KEYS: # unknown kid = token rejected
raise jwt.InvalidTokenError("unknown kid")
data = jwt.decode(
token, KEYS[kid].public,
algorithms=["EdDSA"], audience=AUD, issuer=ISS,
options={"require": ["exp", "iat", "sub", "jti"]}, leeway=30,
)
if data["iat"] < revoked_before(data["sub"]): # panic button
raise jwt.InvalidTokenError("user's sessions were invalidated")
return dataThe only thing read from the token before verification is the kid, and only to choose among keys we already trust (if it's not in KEYS, it's out): the token never supplies verification material of its own, it only points to which of ours to use. Compare this module with create_session_BAD: the same problem, two course modules apart.
Common Mistakes and Tips
- Mistake: believing the JWT is encrypted because "it's unreadable." It's Base64URL: readable in two lines of Python. Nothing secret in the payload; that's what JWE is for.
- Mistake:
jwt.decode(token, key)withoutalgorithms=[...]. Modern PyJWT fails, but on older libraries/versions it's the door toalg: noneand the RS256→HS256 confusion. Always write the closed list, in every language. - Mistake: using HS256 with third parties. Handing the verification key to Robles Pharmacy is handing over the issuing key too. Third parties = asymmetric (EdDSA/RS256), public key for them, private key in the manager.
- Mistake: access tokens lasting hours or days, "to not bother the user." That's what the refresh token is for; a long-lived access token turns every theft into a persistent, unrevocable session.
- Mistake: storing the refresh token in the clear in the DB.
sha256(token), like 03-03's recovery tokens: verifying doesn't require recovering. - Tip: log
jtiandsubin access logs (never the whole token): auditing "what did this token do" is worth gold during an incident.
Exercises
- The attack, with your own hands: using
create_session_BAD, generate the token for("ana.perez", "patient"), modify it to get thedoctorrole, and verify thatread_session_BADaccepts it. Then try the same attack against a token issued byissue(), and explain exactly at which line ofverify()it dies, and with which exception. - Auditing a decode: this code verifies the tokens MediNube issues for Robles Pharmacy. Find the three problems:
jwt.decode(token, public_key, algorithms=["EdDSA", "HS256"], options={"verify_exp": False}). - Rotation design: describe (no code, 5-8 lines) the full procedure for rotating the portal's signing key using
kid, such that no active session gets cut off: what gets created, what signs, what only verifies, and when the old key gets deleted.
Solutions
- With the
_BADtoken, the attack from the first section works exactly as shown. Againstissue(): after editing the payload, the Ed25519 signature no longer matchesheader.payload, andjwt.decode(...)insideverify()raisesjwt.InvalidSignatureError(the first cryptographic check). To "re-sign," you'd need the private key, which lives in the manager. If you also changed thekidto a made-up one, it would die even earlier, at the "unknown kid"raise. - (a)
algorithms=["EdDSA", "HS256"]: not really a closed list — it enables algorithm confusion: an attacker crafts an HS256 token signed with the (known) public key as the HMAC key; the list must be["EdDSA"]alone. (b)verify_exp: False: tokens never expire; a token leaked in a 2026 log still opens doors in 2030. (c) Missingaudienceandissuer: a token issued for another service (or another issuer whose public key we happen to know) would be accepted here. Bonus: withoutoptions={"require": [...]}, a token missingexpwould pass even withverify_expturned on. - Generate the new keypair in the manager under kid
2027-01→ add it toKEYSin every verifier first (deployment 1: nobody signs with it yet, everyone can already verify it) → switchACTIVE_KIDto the new one (deployment 2: new tokens go out withkid: 2027-01; old ones keep verifying against the previous key) → wait at leastTTL_ACCESS+leeway(plus the refresh token's lifetime if it signs anything too) → remove the old key fromKEYSand destroy it in the manager. No live session gets cut off, because both keys verify throughout the whole window. It's rotation with no downtime: the same choreography asv1/v2in 06-01.
Conclusion
The course's oldest debt is settled: 01-02's eyJ... was Base64 dressed up as a token, and now the portal issues EdDSA JWTs with complete claims, closed verification expectations, a 15-minute access token, a rotating refresh token stored as a hash, and a kid to rotate the signing key without cutting off sessions. Along the way you learned to distrust a token that has opinions about its own verification — alg: none and the RS256→HS256 confusion are the same lesson: the verifier is in charge.
With this, MediNube has no pending pieces left: everything the course flagged as "legacy" now has its correct version. The next lesson is different from all the others: you won't learn anything new — you're going to audit. We'll gather all the course's _BAD examples into an error gallery organized by family, build the reviewer's checklist, and put you up against fragments of legacy code with several combined mistakes, just like a real review. It's time to train your eye. See you at the audit.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
