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

  1. The 01-02 debt: the token anyone could edit
  2. Stateful sessions vs. self-contained tokens
  3. Anatomy of a JWT: header, payload, signature
  4. Signing and verifying with PyJWT: HS256 and EdDSA
  5. Classic attacks against JWT, and their defense
  6. Short expiration and refresh tokens
  7. Revocation: the price of being self-contained
  8. Where to store the token in the browser
  9. 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 it

Golden 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.

  1. 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's create_session_BAD wearing a standard's costume.
  2. 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 alg will verify the HMAC against the public key — and pass! The public key was meant for verifying RSA, not for being a symmetric secret.
  3. 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), or iss (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 as sha256(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:

  1. 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.
  2. A revocation list by jti: a table/Redis with revoked jtis until their exp. The verifier checks the list... and you've just reintroduced a lookup per request — the honest hybrid: only sensitive endpoints check it.
  3. Per-user invalidation: store a not_valid_before timestamp per user; any token with an earlier iat dies. 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 (or Strict): JavaScript can't read it (HttpOnly neutralizes theft via XSS), it only travels over HTTPS (Secure, backed by 05-02's HSTS), and SameSite cuts 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 data

The 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) without algorithms=[...]. Modern PyJWT fails, but on older libraries/versions it's the door to alg: none and 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 jti and sub in access logs (never the whole token): auditing "what did this token do" is worth gold during an incident.

Exercises

  1. The attack, with your own hands: using create_session_BAD, generate the token for ("ana.perez", "patient"), modify it to get the doctor role, and verify that read_session_BAD accepts it. Then try the same attack against a token issued by issue(), and explain exactly at which line of verify() it dies, and with which exception.
  2. 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}).
  3. 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

  1. With the _BAD token, the attack from the first section works exactly as shown. Against issue(): after editing the payload, the Ed25519 signature no longer matches header.payload, and jwt.decode(...) inside verify() raises jwt.InvalidSignatureError (the first cryptographic check). To "re-sign," you'd need the private key, which lives in the manager. If you also changed the kid to a made-up one, it would die even earlier, at the "unknown kid" raise.
  2. (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) Missing audience and issuer: a token issued for another service (or another issuer whose public key we happen to know) would be accepted here. Bonus: without options={"require": [...]}, a token missing exp would pass even with verify_exp turned on.
  3. Generate the new keypair in the manager under kid 2027-01 → add it to KEYS in every verifier first (deployment 1: nobody signs with it yet, everyone can already verify it) → switch ACTIVE_KID to the new one (deployment 2: new tokens go out with kid: 2027-01; old ones keep verifying against the previous key) → wait at least TTL_ACCESS + leeway (plus the refresh token's lifetime if it signs anything too) → remove the old key from KEYS and 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 as v1/v2 in 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.

© Copyright 2026. All rights reserved