🐍 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:
- Session:
Cookie: sid=…. The browser sends it automatically. State lives on the server. - JWT: usually
Authorization: Bearer eyJ…, sometimes in a cookie. State lives in the token.
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.
- HttpOnly: page JavaScript can't read it, so an XSS bug can't steal the id.
- Secure: only sent over HTTPS (that's why the test client uses
https://). - SameSite=Lax: not sent on cross-site POSTs, which blocks most CSRF.
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.
- 9:05 AM: contractor Kiran logs in; token says
"role": "admin",exp= tomorrow 9:05 AM. - 11:40 AM: Kiran's contract ends. Ops sets
role = 'none'in theuserstable. - 4:02 PM: Kiran calls
DELETE /admin/drivers/88with the morning's token.
How auth actually breaks
- Secrets in the payload. Phone numbers, PAN, internal flags: all readable by anyone holding the token.
jwt.decodewithoutalgorithms=[...]. Pin the algorithm. PyJWT 2 refuses to decode without it; older libraries happily acceptedalg: none.- Long-lived access tokens. A 30-day JWT is a 30-day key you can't change locks on.
- "Logout" that only deletes the client copy. A copied JWT still works until
exp. - Tokens in
localStorage. Any XSS reads them. AnHttpOnlycookie can't be read by script. - Checking role, not ownership. Rider 42 fetching ride 9001 that belongs to rider 77 is a 403 your role check never asks about.
- Fast or reversible password storage. MD5, SHA-256, or "encrypted with our key" are all breaches waiting.
- Weak HMAC secret. PyJWT warns below 32 bytes; a guessable secret lets anyone mint admin tokens.
Words to know
| Authentication | Proving who the caller is: a password once, then a session id or token on each request. |
|---|---|
| Authorization | Deciding whether this caller may do this action on this specific resource. |
| Salt | Random bytes stored beside a password hash so identical passwords hash differently. |
| Session | Server-side state keyed by a random id the client holds in a cookie. |
| JWT | JSON Web Token: base64url header and payload plus a signature over both. Signed, not encrypted. |
| exp claim | Expiry timestamp inside a JWT; libraries reject the token after it (ExpiredSignatureError in PyJWT). |
| Denylist | A server-side set of revoked token ids (jti) checked on every request: the only way to kill a JWT early. |
| CSRF | A foreign site making the browser send a request with your cookies attached; SameSite cookies block most of it. |
In two minutes
- Authentication proves who you are; authorization decides what you may do, per object, on every request.
- Passwords are stored as slow, salted, one-way hashes (Argon2id, scrypt, bcrypt), never encrypted and never plain SHA-256.
- A session is a random id in a cookie; the server keeps the state, so logout and bans take effect instantly.
- Session cookies get HttpOnly (no JS access), Secure (HTTPS only) and SameSite (blocks most CSRF).
- A JWT is header.payload.signature in base64url: signed so it can't be altered, but readable by anyone.
- A JWT can't be revoked by itself; short expiry, refresh tokens, or a jti denylist add the state back.
- Return 401 when you don't know who the caller is, 403 when you do and the answer is no.
Test yourself
1. 401 vs 403: a logged-in rider with a valid session calls GET /admin/payouts. What should the API return?
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 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?
- JWTs have a 100-byte limit
- The signature would break
- The payload is only base64url-encoded, so anyone holding the token can read it
- 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?
- Each guess an attacker makes against a leaked hash costs the same work, so slow hashes make brute force impractical
- So users can't log in too often
- Slow hashes are shorter to store
- 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?
- It decrypts the payload
- It compares the payload to the copy it stored at login
- It checks the exp claim
- 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?
- It returns the claims with a warning
- It raises jwt.ExpiredSignatureError
- It returns None
- 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?
- None: both stop working immediately
- The JWT stops immediately; the session lasts until Max-Age
- Deleting the session row kills it on the next request; the JWT keeps working until exp unless you check a denylist
- 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.
Start this lesson →