Module 3 · Postgres & Data · Deep Dive
Hash passwords properly, mint stateless tokens, and lock down routes — the part of the backend an interviewer will probe, because getting it wrong leaks user data.
BasicIntermediateBuild
Two words that sound alike and get muddled in interviews. Keep them straight:
This lesson is mostly about authN — proving identity and carrying that proof on every request. AuthZ (roles, ownership checks) builds on top once you know who the caller is.
PHP bridge: authN is your oldlogin.php checking the password; authZ is the if ($user['role'] === 'admin') check you scattered through the app afterwards.
The first rule of storing passwords: you don't store passwords. You store a one-way hash. If your database leaks, attackers get gibberish, not logins. A modern, deliberately-slow algorithm like bcrypt is the standard — slow is a feature, because it makes brute-forcing expensive.
In Python, reach for passlib with a CryptContext. It picks the algorithm, salts automatically, and gives you two methods: hash on register, verify on login.
# pip install "passlib[bcrypt]" from passlib.context import CryptContext pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") # On register — store the HASH, never the raw password hashed = pwd.hash("correct horse battery") # -> "$2b$12$Q9.../...kZ8e" (a different string every time, thanks to the salt) # On login — verify the typed password against the stored hash pwd.verify("correct horse battery", hashed) # True pwd.verify("wrong guess", hashed) # False
You never "decrypt" a hash — there's no key. You re-hash the candidate and compare. verify does that comparison for you in constant time.
pwd.hash() ≈ password_hash($pw, PASSWORD_BCRYPT) and pwd.verify() ≈ password_verify($pw, $hash). Same idea, same bcrypt under the hood — just Python syntax.
hashlib.sha256; plain SHA is too fast and unsalted, which is exactly what bcrypt was built to avoid.
Once a user proves who they are, you don't want them re-typing the password on every request. You hand them a token they present each time. A JWT (JSON Web Token, say "jot") is the dominant format. It's one string with three dot-separated parts:
eyJhbGciOiJ... . eyJzdWIiOiJ... . SflKxwRJSM... <--- header ---> <--- payload ---> <--- signature --->
| Part | Holds | Notes |
|---|---|---|
header | Algorithm + token type | e.g. {"alg":"HS256","typ":"JWT"} |
payload | Claims (the data) | e.g. user id, expiry. Base64 — readable, not secret. |
signature | Tamper proof | header+payload signed with your secret key. |
The header and payload are just Base64-encoded JSON, not encrypted — anyone can decode and read them. What stops tampering is the signature: it's computed from the header, payload, and a SECRET_KEY only your server knows. Change one byte of the payload and the signature no longer matches, so the server rejects it.
sub ("subject"), an expiry exp, maybe a role. Never put passwords, secrets, or anything you'd mind a user reading. Treat it as public data with a tamper-proof seal.
The big win is that JWTs are stateless: the server doesn't store sessions. It just verifies the signature on each request. That scales cleanly and is why interviewers like asking about it.
PHP bridge: instead of a$_SESSION kept on the server (or in Redis), the proof of login travels with the client in the token. No server-side session store to keep in sync.
FastAPI ships first-class auth helpers. OAuth2PasswordBearer tells FastAPI "expect a token in the Authorization: Bearer <token> header" and wires it into the docs. A /login endpoint verifies the password and issues a signed JWT with an expiry.
# pip install python-jose[cryptography] (or: pyjwt) import os from datetime import datetime, timedelta, timezone from jose import jwt from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm SECRET_KEY = os.environ["SECRET_KEY"] # from the environment, NOT hard-coded ALGORITHM = "HS256" TOKEN_TTL_MIN = 30 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") app = FastAPI() def make_token(sub: str) -> str: expire = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_TTL_MIN) claims = {"sub": sub, "exp": expire} # exp = expiry, in seconds since epoch return jwt.encode(claims, SECRET_KEY, algorithm=ALGORITHM) @app.post("/login") def login(form: OAuth2PasswordRequestForm = Depends()): user = get_user(form.username) # load from DB if not user or not pwd.verify(form.password, user.hashed_password): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bad credentials") return {"access_token": make_token(user.email), "token_type": "bearer"}
The exp claim is special: encoders write it, and decoders automatically reject the token once that time passes. You don't hand-check it — the library raises an error, which you turn into a 401.
Now the elegant part. You write one dependency, get_current_user, that pulls the token off the request, decodes it, and loads the user. Any route that adds Depends(get_current_user) is instantly protected — and receives the user object as a parameter.
from jose import JWTError credentials_error = HTTPException( status.HTTP_401_UNAUTHORIZED, "Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) def get_current_user(token: str = Depends(oauth2_scheme)): try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) email = payload.get("sub") if email is None: raise credentials_error except JWTError: # bad signature OR expired -> 401 raise credentials_error user = get_user(email) if user is None: raise credentials_error return user @app.get("/me") def read_me(user = Depends(get_current_user)): return {"email": user.email} # only reachable with a valid token
That's the whole pattern. jwt.decode verifies the signature and the exp in one call — an invalid or expired token raises JWTError, which becomes a clean 401. Protecting a new endpoint is now a one-line decision.
Depends(get_current_user) is the middleware/requireLogin() guard you put at the top of protected PHP controllers — except FastAPI injects the resolved user straight into your handler.
Auth code is where small slips become big incidents. The non-negotiables:
SECRET_KEY = os.environ["SECRET_KEY"], loaded from a .env that's git-ignored. A leaked secret means anyone can forge valid tokens. Generate a strong one: python -c "import secrets; print(secrets.token_hex(32))".JWTError and respond with a generic "could not validate credentials". Don't leak why it failed.get_current_user dependency that 401s on a bad or expired token." Say that cleanly and you've passed the auth question.
/register that hashes the password, a /login that verifies it and issues a JWT, and a protected /me that only works with a valid token. This is the gate every other DocChat route will sit behind.
A self-contained version you can run with an in-memory user store, then swap for your Postgres + SQLAlchemy models from Lesson 3.2:
auth.py
import os from datetime import datetime, timedelta, timezone from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from passlib.context import CryptContext from jose import jwt, JWTError from pydantic import BaseModel SECRET_KEY = os.environ["SECRET_KEY"] ALGORITHM, TTL = "HS256", 30 pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") app = FastAPI() users: dict = {} # email -> {"email", "hashed_password"} (swap for the DB) class Register(BaseModel): email: str password: str @app.post("/register") def register(body: Register): if body.email in users: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Email already registered") users[body.email] = {"email": body.email, "hashed_password": pwd.hash(body.password)} return {"email": body.email} # note: never return the hash or password @app.post("/login") def login(form: OAuth2PasswordRequestForm = Depends()): user = users.get(form.username) if not user or not pwd.verify(form.password, user["hashed_password"]): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bad credentials") exp = datetime.now(timezone.utc) + timedelta(minutes=TTL) token = jwt.encode({"sub": user["email"], "exp": exp}, SECRET_KEY, algorithm=ALGORITHM) return {"access_token": token, "token_type": "bearer"} def get_current_user(token: str = Depends(oauth2_scheme)): err = HTTPException(status.HTTP_401_UNAUTHORIZED, "Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}) try: email = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]).get("sub") except JWTError: raise err user = users.get(email) if user is None: raise err return user @app.get("/me") def me(user = Depends(get_current_user)): return {"email": user["email"]}
Run it and walk the flow: register, login to get a token, then call /me with Authorization: Bearer <token>. Try a tampered token and confirm you get a 401.
# set the secret first (one-off): export SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))") uvicorn auth:app --reload curl -X POST localhost:8000/register -H "Content-Type: application/json" \ -d '{"email":"sam@uae.dev","password":"hunter2pls"}' # login uses form fields (username/password), not JSON: curl -X POST localhost:8000/login -d "username=sam@uae.dev&password=hunter2pls" # -> {"access_token":"eyJ...","token_type":"bearer"} curl localhost:8000/me -H "Authorization: Bearer eyJ..." # -> {"email":"sam@uae.dev"}
Here's the tension you set up earlier: a short access token is safe (a stolen one expires in minutes) but annoying (users get logged out constantly). A long access token is convenient but dangerous (a stolen one is valid for days). The 2026-standard answer is to stop choosing — issue two tokens:
| Token | Lifetime | Job |
|---|---|---|
access | ~15 min | Sent on every request. Short, so a theft window is tiny. |
refresh | ~7–30 days | Used only to mint a new access token. Sent rarely, stored carefully. |
The access token does the everyday work and expires fast. When it dies, the client quietly calls /refresh with the long-lived refresh token and gets a fresh access token — no re-login. You get the safety of short tokens and the convenience of staying logged in. This limits the blast radius of a leaked access token without punishing the user.
# Two TTLs, two token "types" — the type goes in a claim so /refresh # can reject an access token being misused as a refresh token. ACCESS_TTL_MIN = 15 REFRESH_TTL_DAYS = 30 def make_token(sub: str, kind: str, delta: timedelta) -> str: claims = {"sub": sub, "type": kind, "exp": datetime.now(timezone.utc) + delta} return jwt.encode(claims, SECRET_KEY, algorithm=ALGORITHM) @app.post("/refresh") def refresh(response: Response, refresh_token: str = Cookie(None)): err = HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid refresh token") if refresh_token is None: raise err try: payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) except JWTError: raise err if payload.get("type") != "refresh": # must be a refresh token, not an access one raise err jti, sub = payload.get("jti"), payload.get("sub") if not is_active_refresh(sub, jti): # server-side record: revoked? already used? raise err # --- rotation: retire the old one, issue a brand-new refresh token --- new_refresh, new_jti = mint_refresh(sub) rotate_refresh(sub, old=jti, new=new_jti) set_refresh_cookie(response, new_refresh) access = make_token(sub, "access", timedelta(minutes=ACCESS_TTL_MIN)) return {"access_token": access, "token_type": "bearer"}
Two details that separate a textbook answer from a real one:
httpOnly cookie, not localStorage. An httpOnly cookie can't be read by JavaScript, so an XSS payload can't steal it. The browser attaches it automatically only to your /refresh path. Keep the access token in memory (a JS variable) where it lives briefly and dies on refresh.jti — JWT ID — tracked in your DB or Redis). If a stolen refresh token is replayed after the legitimate client already rotated, the reused jti is no longer active → you reject it and can revoke the whole family. That server-side record is also what finally lets you log a user out — drop their refresh token's jti and it can never mint another access token.# httpOnly cookie: invisible to JS, scoped to the refresh route, HTTPS-only def set_refresh_cookie(response: Response, token: str): response.set_cookie( key="refresh_token", value=token, httponly=True, secure=True, samesite="strict", path="/refresh", max_age=REFRESH_TTL_DAYS * 24 * 3600, )
exp." Then give the practical fix: "So in production I keep access tokens very short — 15 minutes — and put the real session lifetime in a refresh token I do track server-side. Logout deletes that refresh token's record, so no new access tokens can be minted, and the current access token dies on its own within minutes. If I need instant kill-switch revocation, I add a denylist of token jtis in Redis that get_current_user checks — but that reintroduces the per-request state JWTs were meant to avoid, so I only reach for it when the requirement demands it." Naming the trade-off out loud is what scores the point.
So far every logged-in user is equal. Real apps need role-based access control: an admin can delete any document, a normal user only their own. Since the JWT already travels on every request, the cleanest place for the role is a claim inside the token — mint it at login, read it in a dependency, no extra DB hit.
# at login, stamp the user's role into the token token = make_token(user["email"], "access", timedelta(minutes=ACCESS_TTL_MIN)) # make_token now also writes "role": user["role"] into the claims
Now a dependency factory: a function that returns a dependency, pre-configured with the role you require. This is the elegant FastAPI idiom — one factory, reused on any route.
from fastapi import Depends, HTTPException, status def require_role(role: str): def checker(user = Depends(get_current_user)): if user.get("role") != role: raise HTTPException( status.HTTP_403_FORBIDDEN, # authenticated, but NOT allowed "Insufficient permissions", ) return user return checker # a dependency, configured for this role @app.delete("/admin/users/{email}") def delete_user(email: str, admin = Depends(require_role("admin"))): # only reached if the caller's token carries role == "admin" users.pop(email, None) return {"deleted": email}
The status codes matter, and interviewers test the distinction:
get_current_user raises 401; require_role raises 403.
For finer control than coarse roles, use scopes — granular permission strings like docs:read or docs:write carried in the token. A role is a bundle of scopes; FastAPI's Security(..., scopes=[...]) wires this in natively. Roles answer "what kind of user?", scopes answer "what exact action?". Start with roles; reach for scopes when one role needs partial permissions.
require_role("admin") is your scattered if ($user['role'] === 'admin') checks — now a single reusable guard you bolt onto a route with Depends(...), instead of copy-pasting the same if into every controller.
Two loose ends worth a sentence each in an interview:
exp, you can't "un-sign" it. Two production strategies: (1) short access TTL + refresh rotation — logout deletes the server-side refresh record, and the access token expires within minutes on its own (the common default); or (2) a denylist of jtis in Redis that get_current_user checks each request, for when you need instant revocation. The denylist trades away statelessness, so use it only when the requirement justifies it.bcrypt is still fine, but argon2id is the modern winner — it's memory-hard, which resists GPU and ASIC cracking that bcrypt is more exposed to. It's a one-line swap with passlib, and naming it signals you're current.# pip install "passlib[argon2]" — argon2id is the 2026-preferred default pwd = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto") # lists argon2 first for new hashes; still verifies old bcrypt hashes, # and deprecated="auto" re-hashes them to argon2 on next successful login
httpOnly cookie — that's also my logout mechanism. Roles ride in a token claim and a require_role dependency 403s the wrong role, distinct from the 401 for no identity. Passwords are argon2id via passlib, with an optional Redis jti denylist when instant revocation is required."
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
Authentication is best described as proving what?
How should a user password be stored at rest?
Which JWT part makes tampering detectable?
Where must the signing secret key live?
A request with an expired token should return?
A valid token with the wrong role returns?
Where should a refresh token be stored?
OAuth2PasswordBearer, password hashing, JWTs, and protected routes — the exact patterns above, kept current.