Module 3 · Postgres & Data · Deep Dive

Auth with JWT

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

Why this matters DocChat stores private documents. The moment users upload files, "who is this request?" stops being optional. You already shipped login forms in PHP — so the shape is familiar. Here we do it the 2026 way: bcrypt for passwords, JWT for stateless sessions, and FastAPI dependencies to guard every route. Sloppy auth is the fastest way to fail a technical round; solid auth is a thing you can confidently whiteboard.
In this lesson
  1. Authentication vs authorization
  2. Hashing passwords with bcrypt
  3. JWT explained: the three parts
  4. The FastAPI login flow
  5. Protecting routes with a dependency
  6. Security must-dos
  7. Build: auth for DocChat
  8. Refresh tokens: the two-token pattern
  9. Roles & RBAC: 401 vs 403
  10. Logout, revocation & argon2
  11. Check yourself

1 · Authentication vs authorization

Two words that sound alike and get muddled in interviews. Keep them straight:

What's the difference? Authentication (authN) answers "who are you?" — proving identity, usually with a password. Authorization (authZ) answers "what are you allowed to do?" — checking permissions once identity is known. You log in (authN), then the server decides you can read your documents but not someone else's (authZ).

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 old login.php checking the password; authZ is the if ($user['role'] === 'admin') check you scattered through the app afterwards.

2 · Hashing passwords — never plaintext

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.

PHP bridge: you already know this! pwd.hash()password_hash($pw, PASSWORD_BCRYPT) and pwd.verify()password_verify($pw, $hash). Same idea, same bcrypt under the hood — just Python syntax.
The trap that fails audits Never log, return, or store the raw password — not even "temporarily". Hash it the instant it arrives and let the plaintext fall out of scope. And never hand-roll your own hashing with hashlib.sha256; plain SHA is too fast and unsalted, which is exactly what bcrypt was built to avoid.

3 · JWT explained: the three parts

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 --->
PartHoldsNotes
headerAlgorithm + token typee.g. {"alg":"HS256","typ":"JWT"}
payloadClaims (the data)e.g. user id, expiry. Base64 — readable, not secret.
signatureTamper proofheader+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.

What's safe to put in the payload Because the payload is readable, put only non-sensitive identifiers there: a user id or email in 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.

4 · The FastAPI login flow

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.

5 · Protecting routes with a dependency

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.

PHP bridge: 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.

6 · Security must-dos

Auth code is where small slips become big incidents. The non-negotiables:

Interview-ready one-liner "Passwords are bcrypt-hashed with passlib, sessions are stateless JWTs signed with an env-stored secret, tokens are short-lived, and every protected route resolves the user through a single get_current_user dependency that 401s on a bad or expired token." Say that cleanly and you've passed the auth question.

7 · Build it

Your tangible win Give DocChat real accounts: a /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"}

8 · Refresh tokens: the two-token pattern

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:

TokenLifetimeJob
access~15 minSent on every request. Short, so a theft window is tiny.
refresh~7–30 daysUsed 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: 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,
    )
Interview answer · "How do you log a user out with JWTs?" The honest answer impresses precisely because it admits the catch: "You can't truly revoke a stateless JWT — that's the trade-off for being stateless. Once signed, it's valid until 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.

9 · Roles & RBAC: 401 vs 403

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:

401 vs 403 — say it precisely 401 Unauthorized means "I don't know who you are" — no token, bad signature, or expired token. The fix is to log in. 403 Forbidden means "I know exactly who you are, and you're not allowed" — a valid token, but the wrong role. Logging in again won't help. 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.

PHP bridge: 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.

10 · Logout, revocation & argon2

Two loose ends worth a sentence each in an interview:

# 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
Interview-ready one-liner "Access tokens are short-lived; the real session lives in a rotating, server-tracked refresh token in an 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."

11 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

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?

Primary source ⭐ FastAPI — Security tutorial. The official, end-to-end walkthrough of OAuth2PasswordBearer, password hashing, JWTs, and protected routes — the exact patterns above, kept current.