◐ ai-teacher

🐍 Backend with Python · Chapter 8

Auth: sessions, JWT, and what a token is not

A JWT is a signed postcard, not a sealed envelope: anyone can read it, nobody can quietly change it, and you cannot take it back once it is posted.

Study this lesson free in the app →

The basics

Authentication answers who are you? Authorization answers what may you do? Auth is both, done on every request.

The problem it solves: HTTP forgets you between requests (be-002), so every request must carry proof of who sent it, without resending the password and without trusting anything the client could forge.

The mental model: log in once, get a claim ticket. A session is a cloakroom token: a random number that means nothing until the server looks it up in its own register. A JWT is a signed letter: the facts are written on it in plain view, and the signature only proves nobody changed them. The register can strike you off instantly; the letter stays valid until it expires.

Words you must know 5

Password hash
slow, salted, one-way (Argon2id, scrypt, bcrypt). Never encryption, never plain SHA-256.
Session cookie
a random id in a cookie flagged HttpOnly, Secure, SameSite.
JWT
header.payload.signature, each part base64url. Signed, not encrypted.
401 vs 403
401 means "I don't know who you are"; 403 means "I know, and the answer is no".
Revocation
killing a credential before it expires. Trivial for sessions; for JWTs it needs a server-side list.

Explained

Copy the middle segment of any JWT your app issues and base64-decode it. No key needed. You'll read "sub", "role", "exp" and anything else someone put in there.

That isn't a bug. A token is a signed postcard: readable by everyone, tamper-evident, and impossible to recall once posted. Most auth mistakes come from treating it like a sealed envelope or a revocable pass.

The society gate in Pune

The register. You show ID at the gate. The guard writes "Ravi, plumber, B-12" against token 417 and hands you a plastic disc that says 417. The disc means nothing by itself; every time you pass, the guard checks the register. If the secretary says "ban Ravi", the guard strikes the line, and disc 417 is dead that second. The cost: every gate must share the same register.

The signed letter. The secretary writes "Ravi, plumber, allowed till 6 pm" and stamps it. Any guard at any gate reads it and checks the stamp; no register needed. But anyone looking over Ravi's shoulder can read it too. And if the secretary changes her mind at 2 pm, the letter still says 6 pm. The only way to cancel it is to give every gate a list of cancelled letters, which is a register again.

Disc = session id. Register = session store. Letter = JWT. Stamp = signature. 6 pm = exp. The cancelled list = a denylist.

Two questions, two status codes

Authentication (authn) proves identity: password, then a session id or token on later requests. Authorization (authz) decides permission: is this user allowed this action on this row?

| Situation | Status | | --- | --- | | No credential, bad signature, expired token, unknown session | 401 Unauthorized (badly named: it means unauthenticated) | | Valid identity, not allowed | 403 Forbidden | | Not allowed, and you don't want to admit the row exists | 404 |

Where the credential travels:

In FastAPI both become a dependency (be-003): user = Depends(current_user) raises 401, and a second check raises 403. Authorization is per object: checking role == "rider" is not enough if rider 42 can fetch /rides/9001 that belongs to rider 77. That's the most common real-world auth bug, and no token format fixes it.

Storing passwords: slow on purpose

import hashlib, hmac, os, time

N, R, P = 2**17, 8, 1                     # OWASP minimum for scrypt: ~128 MiB per hash

def hash_password(password: str) -> str:
    salt = os.urandom(16)                 # fresh salt per user
    digest = hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P, maxmem=2**28)
    return f"scrypt${salt.hex()}${digest.hex()}"

def verify_password(password: str, stored: str) -> bool:
    _, salt_hex, digest_hex = stored.split("$")
    digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt_hex),
                            n=N, r=R, p=P, maxmem=2**28)
    return hmac.compare_digest(digest, bytes.fromhex(digest_hex))   # constant-time

a = hash_password("chai@1234")
b = hash_password("chai@1234")
print(a[:30] + "...")
print(a == b)                                        # same password, different salt
print(verify_password("chai@1234", a), verify_password("chai@1235", a))

t = time.perf_counter()
for _ in range(5):
    verify_password("chai@1234", a)
print(f"scrypt verify: ~{(time.perf_counter() - t) / 5 * 1000:.0f} ms")
t = time.perf_counter()
for _ in range(100_000):
    hashlib.sha256(b"salt" + b"chai@1234").digest()
print(f"sha256:        ~{(time.perf_counter() - t) / 100_000 * 1e6:.2f} µs")

Measured on a laptop: scrypt$4a5b…, False, True False, ~191 ms per scrypt verify vs ~0.24 µs per SHA-256.

That ratio is the point. A leaked table of SHA-256 hashes lets a GPU try billions of guesses a second; scrypt at these settings costs ~800,000× more per guess. The salt makes the same password hash differently for every user, so one cracked hash doesn't crack everyone who chose chai@1234. compare_digest takes the same time however many bytes match.

OWASP's first pick is Argon2id (the argon2-cffi package); scrypt with N=2^17, r=8, p=1 is its stdlib-friendly alternative. Never encrypt passwords: anything you can decrypt, an attacker with your key can too.

A session, end to end

import secrets
from fastapi import FastAPI, Cookie, HTTPException, Response
from fastapi.testclient import TestClient

app = FastAPI()
SESSIONS: dict[str, dict] = {}         # real life: Redis or a sessions table

@app.post("/login")                    # (password check omitted)
def login(response: Response):
    sid = secrets.token_urlsafe(32)    # random; means nothing by itself
    SESSIONS[sid] = {"user_id": 42, "role": "rider"}
    response.set_cookie("sid", sid, httponly=True, secure=True,
                        samesite="lax", max_age=3600)

@app.get("/admin/payouts")
def payouts(sid: str | None = Cookie(default=None)):
    session = SESSIONS.get(sid or "")
    if session is None:
        raise HTTPException(401, "Who are you?")
    if session["role"] != "admin":
        raise HTTPException(403, "I know who you are. No.")
    return {"payouts": []}

@app.post("/logout")
def logout(response: Response, sid: str | None = Cookie(default=None)):
    SESSIONS.pop(sid or "", None)      # server forgets: dead instantly
    response.delete_cookie("sid")

c = TestClient(app, base_url="https://testserver")
print(c.get("/admin/payouts").status_code)
print(c.post("/login").headers["set-cookie"][:12], "...")
print(c.get("/admin/payouts").status_code)
c.post("/logout")
print(c.get("/admin/payouts").status_code)

Prints 401, sid=rPNDzaWR ..., 403, 401. The full header was HttpOnly; Max-Age=3600; Path=/; SameSite=lax; Secure.

Logout deleted the row, so even someone who copied the old sid gets 401 on their next request. That instant kill is what you trade away with JWTs.

Read a JWT, then try to forge one

The signature protects integrity, not secrecy. Never put anything in a payload you wouldn't print on a postcard.

Monday 3 Nov, 4:02 PM

A cab platform in Hyderabad issues access JWTs with a 24-hour lifetime and puts "role" inside. Handlers authorize straight from the token's claims.

How auth actually breaks

Words to know

AuthenticationProving who the caller is: a password once, then a session id or token on each request.
AuthorizationDeciding whether this caller may do this action on this specific resource.
SaltRandom bytes stored beside a password hash so identical passwords hash differently.
SessionServer-side state keyed by a random id the client holds in a cookie.
JWTJSON Web Token: base64url header and payload plus a signature over both. Signed, not encrypted.
exp claimExpiry timestamp inside a JWT; libraries reject the token after it (ExpiredSignatureError in PyJWT).
DenylistA server-side set of revoked token ids (jti) checked on every request: the only way to kill a JWT early.
CSRFA foreign site making the browser send a request with your cookies attached; SameSite cookies block most of it.

In two minutes

Test yourself

1. 401 vs 403: a logged-in rider with a valid session calls GET /admin/payouts. What should the API return?

  1. 401 Unauthorized
  2. 403 Forbidden
  3. 404 Not Found
  4. 400 Bad Request
Show the answer

403 Forbidden — The server knows exactly who the caller is, so it isn't an authentication failure. They're just not allowed: 403. 401 is for a missing, expired or invalid credential, despite its name.

2. Why is a JWT payload the wrong place for a user's PAN number?

  1. JWTs have a 100-byte limit
  2. The signature would break
  3. The payload is only base64url-encoded, so anyone holding the token can read it
  4. PyJWT strips unknown claims
Show the answer

The payload is only base64url-encoded, so anyone holding the token can read it — Base64url is an encoding, not encryption. Split on the dots, decode the middle part, and every claim is in plain text. The signature stops changes, not reading.

3. Why should a password hash be deliberately slow?

  1. Each guess an attacker makes against a leaked hash costs the same work, so slow hashes make brute force impractical
  2. So users can't log in too often
  3. Slow hashes are shorter to store
  4. Fast hashes can be reversed
Show the answer

Each guess an attacker makes against a leaked hash costs the same work, so slow hashes make brute force impractical — SHA-256 runs in about 0.24 µs; scrypt at OWASP settings took about 191 ms in our test. A user waits once at login; an attacker trying billions of guesses pays that cost billions of times. Fast hashes aren't reversible, just cheap to guess against.

4. How does the server know a client didn't edit the payload of an HS256 JWT?

  1. It decrypts the payload
  2. It compares the payload to the copy it stored at login
  3. It checks the exp claim
  4. It recomputes HMAC-SHA256 over header.payload with its secret and compares that with the signature
Show the answer

It recomputes HMAC-SHA256 over header.payload with its secret and compares that with the signature — Editing the payload changes the HMAC input, and without the secret nobody can produce a matching signature. No stored copy is needed, which is the whole appeal of JWTs.

5. What happens when jwt.decode(token, SECRET, algorithms=["HS256"]) is given a correctly signed token whose exp was 10 seconds ago?

  1. It returns the claims with a warning
  2. It raises jwt.ExpiredSignatureError
  3. It returns None
  4. It raises jwt.InvalidSignatureError
Show the answer

It raises jwt.ExpiredSignatureError — PyJWT checks exp by default and raises ExpiredSignatureError ('Signature has expired'). The signature itself is fine, so it is not InvalidSignatureError. Map either one to a 401.

6. What is the difference between a session and a JWT when you ban a user at 2 PM?

  1. None: both stop working immediately
  2. The JWT stops immediately; the session lasts until Max-Age
  3. Deleting the session row kills it on the next request; the JWT keeps working until exp unless you check a denylist
  4. Both last until the browser is closed
Show the answer

Deleting the session row kills it on the next request; the JWT keeps working until exp unless you check a denylist — A session id is only a pointer to server state, so removing the state kills it. A JWT is self-contained: the server checks the signature and expiry, and both are still valid.

There are 9 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →