Module 07 · ~8 hours · Priority: High

Auth From First Principles

Hash a password, sign a token, verify it on every request — by hand, with nothing managed and nothing hidden. Then break it four ways on purpose.

Authentication vs authorisation 401 vs 403

Two words that look alike, abbreviate almost identically, and mean completely different things. Getting them straight now saves you from a codebase where every failure is a 403 and nobody knows why.

What
Authentication answers "who are you?" — authorisation answers "are you allowed to do this?"
Where
Authentication happens once per request, at the edge, before your handler runs. Authorisation happens inside the handler, where the rules live.
When
Every request to a protected endpoint does both, in that order. You cannot check permissions for someone you have not identified.
How
Authentication reads a credential and produces a user. Authorisation compares that user against the thing being touched.

Your City Guide API has a users table from Module 05 and working places endpoints from Module 06. Right now anyone who can reach the port can write to your database. That is the hole this module closes. Two requests make the distinction concrete — same API, entirely different reasons for refusing:

# A - no credential at all
POST /places          (no Authorization header)
→ 401  {"detail": "Not authenticated"}
  # "I do not know who you are. Come back with a token."

# B - a valid token, but someone else's place
DELETE /places/12     Authorization: Bearer <valid token for user 7>
→ 403  {"detail": "You do not own this place"}
  # "I know exactly who you are, user 7. Place 12 belongs to user 3."
401 Unauthorized403 Forbidden
MeansMissing, malformed, expired or invalid credentialValid credential, insufficient permission
Server knows who you are?NoYes
Would a fresh login help?YesNo — you are simply not allowed
Required response headerWWW-Authenticate: BearerNone
  • The status code names are historical and wrong. 401 is called "Unauthorized" but it means unauthenticated. 403 is the one about authorisation. Nobody is going to fix this; memorise it and move on.
  • 401 is a retryable invitation. It carries WWW-Authenticate: Bearer, telling the client what credential to come back with. A 401 without that header is an incomplete answer, and clients that auto-refresh tokens key off it.
  • A 403 can itself be a leak. "This place exists but is not yours" confirms to a stranger that place 12 exists. For anything sensitive, 404 is the safer lie.
  • Authorisation is not a library, it is your business rules. "Only the owner may delete" is a line of your code comparing two integers. No package can know that rule for you.

Never store a password

One job in this section, and it is absolute: a stolen dump of your users table must be useless to whoever stole it.

What
A password hash is a one-way transformation — cheap to compute forwards, infeasible to reverse.
Where
The password_hash column of your users table. The plaintext exists in memory for milliseconds and is never written anywhere.
When
On registration (hash and store) and on login (hash the attempt, compare). Those are the only two moments.
How
passlib with bcrypt: pwd_context.hash(password) and pwd_context.verify(attempt, stored_hash).

Hashing is not encryption. Encryption is reversible on purpose — a key turns the ciphertext back. A hash has no key and no door. You never need to recover a password; you only need to know whether the one someone just typed produces the same hash. "We can email you your password" is a confession that a site stores passwords recoverably.

Now the counter-intuitive part. MD5 and SHA-256 are hashes, and they are the wrong tool here — not because they are broken, but because they are fast. SHA-256 is designed to hash a gigabyte quickly, so a consumer GPU computes billions per second. An attacker holding your table does not reverse anything; they guess passwords and hash each guess. Speed is the attacker's weapon, so the defence is a hash that is deliberately, tunably slow — a GPU does roughly a billion SHA-256 guesses a second and about thirty bcrypt ones per core, which is the difference between a dictionary falling in seconds and falling in years. And a salt — random bytes mixed in before hashing, unique per user. It defeats two things at once: precomputed rainbow tables become useless, because your salt was not part of the attacker's computation; and without it, two users who both chose password123 would have identical hashes, so cracking one cracks both, and the visible repetition tells an attacker which accounts to target first.

# app/security.py     -     pip install "passlib[bcrypt]"
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(plain: str) -> str:
    return pwd_context.hash(plain)

def verify_password(plain: str, stored: str) -> bool:
    return pwd_context.verify(plain, stored)      # constant-time comparison

Call hash_password("hunter2") twice and you get two different strings, because a fresh salt goes into each. Here is what one looks like, and it is worth taking apart:

$2b$12$eIX/dQ8SBu.gKZ7YHRmL8ep0v1sHkYbNqW3lJ9fTtQ4mCxRvZuA0i
 |  | |                      |
 |  | |                      +-- 31 chars: the hash itself
 |  | +-- 22 chars: the salt, generated randomly for THIS user
 |  +-- cost factor 12 = 2^12 = 4096 rounds of key expansion
 +-- algorithm identifier: bcrypt, revision 2b

The salt and the cost live inside the hash string. That design is what makes this practical: no extra columns, and verify() reads the salt and cost back out of the stored value so it can recompute the same hash from the attempt. It also lets you raise the cost as hardware improves — old hashes keep verifying at their old cost, and deprecated="auto" flags them for rehashing on the next successful login.

  • Never write your own. Not "roughly bcrypt", not SHA-256 salted and looped a thousand times. This is the clearest case in backend work where the library is correct and your version is not.
  • bcrypt silently truncates at 72 bytes. Anything past the 72nd byte is ignored, so two different passwords can both work. Cap the length in your schema.
  • verify() is constant-time, and Argon2 is a drop-in upgrade. A naive == would leak through how long the comparison took — a timing attack — and passlib closes that. Switching to schemes=["argon2"] changes nothing else in this module.
If you see AttributeError: module 'bcrypt' has no attribute '__about__', nothing is broken. passlib 1.7.4 reads bcrypt's version from an attribute that bcrypt 4.1 removed, and logs a trapped error at import. Hashing still works. Pin bcrypt==4.0.1 if the noise bothers you.

Register and login

What
Two endpoints: one creates a user with a hashed password, one exchanges an email and password for a token.
Where
app/routers/auth.py, using the get_db dependency from Module 05 and the response-model pattern from Module 06.
When
Before anything can be protected. Nothing else in this module works until these two do.
How
Register hashes and inserts; login looks up, verifies, and signs a token.
# app/schemas/auth.py
class RegisterIn(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=72)   # 72 = bcrypt's limit

class UserOut(BaseModel):                    # note: no password field. Ever.
    id: int
    email: EmailStr
    model_config = {"from_attributes": True}

class TokenOut(BaseModel):
    access_token: str
    token_type: str = "bearer"                # LoginIn is RegisterIn without Field

UserOut having no password field is not an oversight, it is the mechanism. Because Module 06 taught you to declare response_model=UserOut, FastAPI filters the ORM object down to exactly those fields on the way out — so even if a later refactor returns the whole User row, the hash cannot escape through this endpoint.

# app/routers/auth.py
from app.db import get_db                    # Module 05
from app.security import hash_password, verify_password, create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])

@router.post("/register", response_model=UserOut, status_code=201)
def register(body: RegisterIn, db: Session = Depends(get_db)):
    if db.query(User).filter(User.email == body.email).first():
        raise HTTPException(status_code=409, detail="Email already registered")
    user = User(email=body.email, password_hash=hash_password(body.password))
    db.add(user); db.commit(); db.refresh(user)
    return user

@router.post("/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.email == body.email).first()
    # ONE error for BOTH failure modes. Read the note below before "improving" it.
    if user is None or not verify_password(body.password, user.password_hash):
        raise HTTPException(status_code=401,
                            detail="Incorrect email or password",
                            headers={"WWW-Authenticate": "Bearer"})
    return TokenOut(access_token=create_access_token(subject=str(user.id)))

That single shared error message is the most important line in this section. The obvious, friendly design returns "no account with that email" and "wrong password" separately. Do not. An attacker with ten million email addresses then feeds each to /login with a junk password and sorts your users from your non-users — user enumeration, which turns login into a membership oracle. For a dating site, a medical service or a job board, knowing an address has an account is the leak.

  • Registration is the awkward exception. /register returning 409 "already registered" leaks the same fact. Avoiding it fully needs an email-confirmation flow that always says "check your inbox". Ship the 409 for now — but know why it is there.
  • Verify against a dummy hash when the user does not exist. Otherwise the missing-user branch returns in a microsecond while the wrong-password branch spends 250ms in bcrypt, and the timing rebuilds the oracle you just closed.
  • Login is a POST, always. A GET puts the password in the URL, and URLs land in browser history, access logs, and the Referer header of the next request.
  • Add a unique index on users.email in an Alembic migration. The if check races two simultaneous registrations; the database constraint is what actually prevents duplicates.

What a JWT actually is

A JWT is not magic and it is not encrypted. It is three chunks of base64 with dots between them, and once you have read one by hand it stops being mysterious forever.

What
A JSON Web Token: a header, a payload of claims, and a signature — each base64url-encoded and joined with dots.
Where
In the Authorization: Bearer <token> header of every request to a protected endpoint.
When
Issued at login, presented on each later request, discarded when it expires.
How
jwt.encode(payload, secret, algorithm="HS256") to sign, jwt.decode(token, secret, algorithms=["HS256"]) to verify.

Here is a real one, signed with the JWT_SECRET from your Module 00 .env. Decode the first two segments — no secret required, which is the entire point of this section:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3IiwiZXhwIjoxNzY3MjI1NjAwLCJpYXQiOjE3NjcxMzkyMDB9.7GuDUY6vZQ_NoI6vK4mA_ECW-hHVWRxFrc9RQ3qiepw
<----------- header ---------------> <---------------- payload -----------------> <------------ signature ------------->

header  = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": "7", "exp": 1767225600, "iat": 1767139200}
           |           |                  |
           |           |                  +-- issued at: 2025-12-31 00:00:00 UTC
           |           +-- expires at:            2026-01-01 00:00:00 UTC
           +-- subject: WHO this token is about - user id 7 in your users table

Those three claim names are registered in RFC 7519, which is why every JWT library everywhere understands them. sub is the identity the token asserts — use the database id, not the email, because emails change. exp is a Unix timestamp after which the token is invalid, and libraries enforce it for you. iat records when it was minted, which lets you invalidate everything issued before a chosen moment.

base64 is encoding, not encryption. Anybody holding the token can read the payload — no key, no secret, one line of Python. A JWT payload is public data. Never put a password, a card number, a home address, or anything you would not print on a postcard into it. What the signature buys you is integrity and origin — nobody can alter the payload without invalidating it — and nothing whatsoever about secrecy.

Why the server stores nothing. The signature is HMAC-SHA256(header + "." + payload, secret). When a token comes back, the server recomputes that HMAC over the segments it was handed and compares. A match means the payload is byte-for-byte what it originally signed, because producing a matching signature requires the secret. No lookup, no session table, no database round-trip — verification is pure arithmetic, which is why any number of servers can verify independently.

LOGIN EVERY LATER REQUEST verify password read the Authorization header | | payload = {sub, iat, exp} split into 3 segments | | HMAC(header.payload, SECRET) --> recompute HMAC(header.payload, SECRET) | | header.payload.signature --sent--> equal? AND exp still in the future? yes -> you are user 7 | no -> 401

And now the price. Because the server stores nothing, it cannot un-issue anything. Change a user's password, disable the account, fire them — a token already in the wild keeps verifying until exp passes, because verification never consults your database about validity. There is no session row to delete. That single fact is why access tokens are minutes long rather than weeks, and it is the trade-off the rest of this module is organised around.

# app/security.py (continued)   -   pip install pyjwt
import os, jwt
from datetime import datetime, timedelta, timezone

JWT_SECRET = os.environ["JWT_SECRET"]      # from .env - Module 00
JWT_ALGORITHM, ACCESS_TOKEN_MINUTES = "HS256", 15   # 15 min: it cannot be revoked

def create_access_token(subject: str) -> str:
    now = datetime.now(timezone.utc)
    return jwt.encode({"sub": subject, "iat": now, "type": "access",
                       "exp": now + timedelta(minutes=ACCESS_TOKEN_MINUTES)},
                      JWT_SECRET, algorithm=JWT_ALGORITHM)

def decode_token(token: str) -> dict:
    # algorithms= is NOT optional. See the warning below.
    return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
Always pass an explicit algorithms list. The token carries its own alg in the header, and a library that trusts that field can be attacked through it. Set alg to none and a naive verifier accepts an unsigned token. Worse, against a service that signs with RSA, an attacker flips alg to HS256 and signs using the public key as if it were an HMAC secret — algorithm confusion, and it has broken real systems. pyjwt demands the list precisely so you cannot make this mistake by accident. Never compute it from the incoming header.
Why HS256 here, and what breaks in Module 08. HS256 is symmetric: one shared secret both signs and verifies. That is correct and simple while a single process does both jobs. The moment a second service needs to verify your tokens you must hand it the signing secret — and anything that can verify can now also mint tokens for any user. Module 08 fixes this by splitting identity into its own service that signs with a private RS256 key and publishes only the public half over JWKS, so verifiers can check tokens without ever gaining the power to issue them.

The auth dependency

What
One function that turns an Authorization header into a User row, or raises 401 trying.
Where
app/deps.py, next to the get_db dependency from Module 05.
When
Declared as a parameter on every endpoint that must not be anonymous.
How
current_user: User = Depends(get_current_user) in the handler signature.

This is where FastAPI's dependency injection earns its keep. A dependency is a function whose result is computed before your handler runs; declaring it as a parameter is how you ask for that result. And dependencies compose — get_current_user itself declares Depends(get_db), so FastAPI resolves the database session first, hands it in, and both are ready by the time your handler body executes.

# app/deps.py
CREDENTIALS_ERROR = HTTPException(
    status_code=401, detail="Could not validate credentials",
    headers={"WWW-Authenticate": "Bearer"})    # required on every 401

def get_current_user(
    authorization: str | None = Header(default=None),
    db: Session = Depends(get_db),             # Module 05
) -> User:
    if authorization is None or not authorization.startswith("Bearer "):
        raise CREDENTIALS_ERROR
    try:
        payload = decode_token(authorization.removeprefix("Bearer ").strip())
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Token has expired",
                            headers={"WWW-Authenticate": "Bearer"})
    except jwt.InvalidTokenError:        # bad signature, malformed, wrong alg
        raise CREDENTIALS_ERROR
    if payload.get("sub") is None or payload.get("type") != "access":
        raise CREDENTIALS_ERROR
    user = db.get(User, int(payload["sub"]))   # deleted since it was issued?
    if user is None:
        raise CREDENTIALS_ERROR
    return user

That last lookup is not redundant. The token already carries the user id, and it is signed, so you could skip the query. Do not. The row is how you notice the account was deleted or disabled after the token was minted — it is the one place a stateless token gets a sanity check against reality.

Now protect the endpoint. One extra parameter, and the handler that let anyone write to your database no longer does:

# app/routers/places.py
@router.post("/places", response_model=PlaceOut, status_code=201)
def create_place(body: PlaceCreate,
                 db: Session = Depends(get_db),
                 current_user: User = Depends(get_current_user)):  # <- the change
    place = Place(**body.model_dump(), owner_id=current_user.id)
    db.add(place); db.commit(); db.refresh(place)
    return place
request -> [ get_db ] -> Session -> [ get_current_user ] -> User row | [ create_place(body, db, current_user) ] <- your code
  • Your handler can now assume it is authenticated. By the time create_place runs, current_user is a real row — there is no if user is None branch to forget, because the request never reached you.
  • Note what owner_id unlocks. Recording who created a place is what makes a 403 possible later: if place.owner_id != current_user.id is your first authorisation rule, and it lives in the handler, not the dependency.
  • Never take the user id from the request body. A client-supplied user_id field is an invitation to act as anyone. Identity comes from the token and nowhere else — this is the most common auth bug in real code.
  • GET stays open on purpose. GET /places is a public city guide; only writes need a token. Protecting everything reflexively is a design decision, not a default.
  • HTTPBearer from fastapi.security does the header parsing for you and adds an Authorize button to /docs. Swap to it once you have written the manual version and know what it replaced.

Break it four ways do all four

This is the heart of the module. Auth code that has only ever been tested by succeeding is auth code you do not understand. Run every one of these and read the actual error.

What
Four deliberate attacks on your own endpoint, each exercising a different branch of get_current_user.
Where
Your terminal, against your own running server, with curl.
When
Immediately after the happy path works, and again any time you touch the auth code.
How
Change one thing, send the request, record the status code and the exception name in the server log.

Start from a real token and keep the repeated parts in variables:

$ J="Content-Type: application/json"
$ B='{"name":"Blue Cafe","neighbourhood":"Southbank","rating":5}'
$ TOKEN=$(curl -s -X POST localhost:8000/auth/login -H "$J" \
    -d '{"email":"you@example.com","password":"hunter2hunter2"}' \
    | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

1. Tamper with the payload. Change one character of the token — turn a 7 into an 8 in the middle segment, or mangle the last byte:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/places \
    -H "$J" -d "$B" -H "Authorization: Bearer ${TOKEN%?}X"
401

Client sees: 401 with {"detail":"Could not validate credentials"}. Server raises: jwt.exceptions.InvalidSignatureError, a subclass of InvalidTokenError. The signature was computed over the original bytes; you changed the bytes; the recomputed HMAC no longer matches. Note what did not happen — the server looked nothing up to know the token was forged.

2. Let a token expire. Mint one with a five-second life, then use it twice:

$ SHORT=$(python3 -c "
import os, jwt; from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
print(jwt.encode({'sub':'7','iat':now,'exp':now+timedelta(seconds=5),'type':'access'},
                 os.environ['JWT_SECRET'], algorithm='HS256'))")
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/places \
    -H "$J" -d "$B" -H "Authorization: Bearer $SHORT"
201                          # works - we are inside the five seconds
# count to ten, then run the exact same command again
401

Client sees: 401 with {"detail":"Token has expired"}. Server raises: jwt.exceptions.ExpiredSignatureError. The signature is still perfectly valid; nothing was tampered with. pyjwt compared exp against the current clock and rejected it on that basis alone. This is the only one of the four where the token is authentic and still refused — sit with that. Expiry is enforced by a verifier reading a number, not by anything remembering the token exists.

3. Send no header at all.

$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/places -H "$J" -d "$B"
{"detail":"Could not validate credentials"}
401

Client sees: 401, plus the WWW-Authenticate: Bearer response header telling it what to come back with — confirm with curl -i. Server raises: nothing from pyjwt. Your own first if catches it before any decoding is attempted. A 401 without that header is the incomplete answer from the first section.

4. Sign with a different secret. The impersonation attempt — an attacker who knows your token format exactly, and everything except the secret:

$ FORGED=$(python3 -c "
import jwt; from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
print(jwt.encode({'sub':'1','iat':now,'exp':now+timedelta(hours=1),'type':'access'},
                 'i-guessed-your-secret', algorithm='HS256'))")
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/places \
    -H "$J" -d "$B" -H "Authorization: Bearer $FORGED"
401

Client sees: 401, identical to experiment 1 — deliberately so, because a distinct message would tell an attacker whether the structure was right. Server raises: InvalidSignatureError again. Decode that forged payload and you will see a perfectly well-formed {"sub":"1",...}: correct in every respect except the one that matters. Now the uncomfortable follow-up — replace i-guessed-your-secret with your real JWT_SECRET and the same request returns 201, creating a place as user 1. Your entire auth system is that one string. That is why it lives in .env, why .env is git-ignored, and why it must be long and random: python3 -c "import secrets; print(secrets.token_urlsafe(32))".

In other stacks: Express uses jsonwebtoken — jwt.sign(payload, secret, {expiresIn: '15m'}) plus a middleware calling jwt.verify and attaching req.user. Django ships authentication and PBKDF2 password hashing in the box, with djangorestframework-simplejwt for tokens. Rails gives you has_secure_password, which is bcrypt behind one macro, and Devise for the full flow. The packaging differs completely; the primitives do not. Every one of them hashes on register, verifies on login, signs a token, and re-verifies that signature on each request — and every one can be broken by these same four experiments.

Access tokens and refresh tokens

Fifteen-minute tokens are the correct answer to "cannot be revoked" and an obviously terrible user experience. Refresh tokens are how both stay true at once.

What
A short-lived access token checked on every request, plus a long-lived refresh token whose only job is obtaining new access tokens.
Where
The access token stays in client memory; the refresh token is stored server-side in a refresh_tokens table — hashed, like a password.
When
The client calls /auth/refresh after a request comes back 401 "expired", then retries the original request.
How
Issue both at login. Verify the refresh token against the table, revoke it, issue a fresh pair.

The asymmetry is the whole design. An access token is stateless and unrevokable, so it must be short. A refresh token is checked against a database row, which means you can revoke it — delete the row and it is dead immediately. You pay one query, but once every fifteen minutes rather than on every request. Statelessness where it is hot, statefulness where you need control.

# refresh_tokens: id, user_id, token_hash, expires_at, revoked_at
# (add it with an Alembic migration - Module 05)

@router.post("/refresh", response_model=TokenOut)
def refresh(body: RefreshIn, db: Session = Depends(get_db)):
    now = datetime.now(timezone.utc)
    row = db.query(RefreshToken).filter(
        RefreshToken.token_hash == sha256_hex(body.refresh_token)).first()
    if row is None or row.revoked_at is not None or row.expires_at < now:
        raise HTTPException(401, "Invalid refresh token")
    row.revoked_at = now                          # rotation: single use only
    new_refresh = issue_refresh_token(db, row.user_id)
    db.commit()
    return TokenOut(access_token=create_access_token(str(row.user_id)),
                    refresh_token=new_refresh)

Rotation, in outline. Each refresh consumes the old token and issues a new one. If an already-revoked refresh token is ever presented again, two parties hold it — the real client and a thief — so the correct response is to revoke every refresh token for that user and force a re-login. You do not need that detection today; you do need to see why single-use rotation is what makes it possible.

  • Hash the refresh token before storing it. It is a credential, so this module's rule applies: a database dump must not hand anyone a working login. Plain SHA-256 is fine here, unlike for passwords, because the token is 32 random bytes and cannot be guessed.
  • The access token is deliberately not in the database. If you find yourself adding a table to check access tokens against, you have reinvented sessions — a legitimate design, just not this one.
  • Logout means deleting the refresh token row. The access token stays valid for up to fifteen more minutes, and that is the accepted cost. There is no way to make it stop, only ways to shorten the window.
  • Where the client stores these is a real decision. localStorage is readable by any script injected into your page; an HttpOnly cookie is not, but brings CSRF concerns of its own. There is no free option, only an informed one.
This is a learning implementation, and shipping it as-is would be a mistake. Real production auth also needs rate limiting on /login — otherwise your slow hash is the only thing between an attacker and a million guesses — account lockout or progressive delays after repeated failures, a password reset flow that does not itself leak account existence, email verification, and a deliberate decision about token storage on the client. The multi-service part, where a shared HS256 secret stops being acceptable, is Module 08. What you have built here is correct in its primitives and incomplete at its edges; knowing exactly which edges is the point.

Build this

Target: a City Guide API where POST /places refuses anonymous requests with a 401 and accepts authenticated ones with a 201, backed by /auth/register, /auth/login and /auth/refresh — with all four break experiments run and their results written down.
  1. Add password_hash to the User model plus an Alembic migration with a unique index on email. Install passlib[bcrypt] and pyjwt; generate a real JWT_SECRET into .env and add the key to .env.example.
  2. Write app/security.py: hash_password, verify_password, create_access_token, decode_token. Hash the same password twice and confirm the two strings differ.
  3. Build POST /auth/register (201, no hash in the body) and POST /auth/login (returns a token, and the same 401 for unknown email and for wrong password).
  4. Write get_current_user in app/deps.py and add current_user: User = Depends(get_current_user) to POST /places. Store owner_id on the place.
  5. Break it on purpose, all four ways — tampered payload, expired token, missing header, wrong secret. For each, record the status code, the response body, and the exception name from the server log in notes/module-07.md.
  6. Add the refresh_tokens table and POST /auth/refresh with single-use rotation, then confirm a used refresh token fails on its second use.

Verify it yourself

Every one of these is objectively pass or fail. Run them in order against a running server; if a status code differs, the module is not finished.

$ J="Content-Type: application/json"
$ B='{"name":"Blue Cafe","neighbourhood":"Southbank","rating":5}'

$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/auth/register -H "$J" \
    -d '{"email":"you@example.com","password":"hunter2hunter2"}'
{"id":7,"email":"you@example.com"}
201                                  # 201, and NO password field in the body

$ docker compose exec db psql -U cityguide -d cityguide \
    -c "SELECT password_hash FROM users WHERE email='you@example.com';"
 $2b$12$eIX/dQ8SBu.gKZ7YHRmL8e...    # starts with $2b$12$ - never the plaintext

$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/auth/login -H "$J" \
    -d '{"email":"nobody@example.com","password":"whatever"}'
{"detail":"Incorrect email or password"}
401                                  # unknown email
$ curl -s -w "\n%{http_code}\n" -X POST localhost:8000/auth/login -H "$J" \
    -d '{"email":"you@example.com","password":"wrongwrongwrong"}'
{"detail":"Incorrect email or password"}
401                                  # wrong password - byte-identical to above

$ TOKEN=$(curl -s -X POST localhost:8000/auth/login -H "$J" \
    -d '{"email":"you@example.com","password":"hunter2hunter2"}' \
    | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# READ YOUR OWN TOKEN - no secret, no library, no permission
$ python3 -c "import sys,base64,json; p=sys.argv[1].split('.')[1]; \
    print(json.loads(base64.urlsafe_b64decode(p + '=' * (-len(p) % 4))))" "$TOKEN"
{'sub': '7', 'iat': 1767139200, 'type': 'access', 'exp': 1767140100}
                                     # exp is iat + 900s. It is all readable.

$ C='curl -s -o /dev/null -w %{http_code}\n -X POST localhost:8000/places'
                                     # $C prints only the status code

$ $C -H "$J" -d "$B"
401                                  # no Authorization header
$ curl -si -X POST localhost:8000/places -d '{}' | grep -i www-authenticate
WWW-Authenticate: Bearer             # this header MUST be present on a 401
$ $C -H "$J" -d "$B" -H "Authorization: Bearer $TOKEN"
201                                  # the same request, with a token
$ $C -H "$J" -d "$B" -H "Authorization: Bearer ${TOKEN%?}X"
401                                  # one character changed - signature fails
$ $C -H "$J" -d "$B" -H "Authorization: Bearer $SHORT"
401                                  # $SHORT from Break it, experiment 2
If the two failed logins return different bodies, stop and fix that before anything else. It is the one defect on this page that is invisible in normal use, impossible to spot from a browser, and directly exploitable by anyone holding a list of email addresses.

Prove it

Write your answers in notes/module-07.md before you open any of these. Where your answer and the given one disagree, work out which is right — that gap is the actual lesson.

1. What is inside a JWT? Name every part and every claim, and say what each one does.

Show answer

Three base64url segments joined by dots. The header declares the signing algorithm (alg: HS256) and type. The payload holds claims: sub is the subject — who the token is about, here the user's database id; exp is a Unix timestamp after which verifiers must reject it; iat records when it was issued, letting you invalidate everything minted before a chosen moment. The signature is HMAC-SHA256(header + "." + payload, secret). Other registered claims you will meet: iss (issuer), aud (audience — which service the token is for, which starts mattering in Module 08), and jti (a unique token id, the basis of any denylist). Header and payload are encoded, not encrypted, and readable by anyone.

2. Why can the server verify a token without having stored it anywhere?

Show answer

Because verification is recomputation, not lookup. The server takes the header and payload segments exactly as received, runs the same HMAC over them with the same secret, and compares the result to the signature segment. A match proves the payload is byte-for-byte what this server signed, since forging a match would require the secret. Nothing needs remembering, which is why any number of servers sharing the secret verify independently with no session store and no database round-trip. The cost is symmetrical to the benefit: a server that remembers nothing about a token also cannot forget one, so there is no revocation. Storing tokens would buy back revocation and cost you exactly the property that made JWTs attractive.

3. Your access token leaks — a proxy log, a screenshot, a browser extension. What can the attacker do, and for how long?

Show answer

Everything that user can do, until exp passes, and nothing you do stops it. Changing the password does not help: verification never consults the password. Deleting the session does not help: there is no session. The mitigations are structural. Short expiry bounds the damage to fifteen minutes. Deleting the user's refresh tokens stops them obtaining a new access token but leaves the current one live. Rotating JWT_SECRET does work — it invalidates the leaked token instantly — but logs out every user on the platform, so it is an emergency lever, not routine. True immediate revocation requires a denylist of jti values checked per request, at which point you have given back the statelessness you came for.

4. Why must "no such user" and "wrong password" return the identical error?

Show answer

Because a different response turns login into a membership oracle. An attacker feeds ten million addresses to /login with junk passwords and sorts registered from unregistered by the reply — user enumeration. For many services the mere fact that an address has an account is the sensitive information: a dating site, a health service, a job board, a support forum for a stigmatised condition. Even where it is not, a confirmed list of real accounts is exactly the input a credential-stuffing attack needs. Note that the body is only half of it — if the missing-user branch returns in a microsecond while the wrong-password branch spends 250ms in bcrypt, the timing rebuilds the same oracle. Hash a dummy password on the missing-user path.

5. SHA-256 is a strong, unbroken cryptographic hash. Why is it the wrong tool for passwords?

Show answer

Because its design goal is speed, and speed is what the attacker needs. Nobody reverses a hash; they guess passwords and hash each guess against your stolen table. A consumer GPU computes billions of SHA-256 hashes per second, so a dictionary of every password anyone has ever used falls in minutes. bcrypt at cost 12 runs 4096 rounds of key expansion per hash, cutting an attacker to tens of guesses per second per core, and the cost is tunable upward as hardware improves. Salting is a separate defence solving a separate problem — it kills precomputed rainbow tables and stops identical passwords producing identical hashes — and salting a fast hash leaves it fast. You need both, which is why bcrypt and Argon2 do both by construction.

6. Why does get_current_user query the database at all, when the signed token already contains the user id?

Show answer

Because the token is a statement about the past — "at 09:00 this was user 7" — and the row is the present. Between issuing and presenting, the account may have been deleted, suspended, had its email changed, or had its role downgraded. The token cannot know any of that; it was signed before it happened. The lookup is the one moment a stateless system checks itself against reality, and it is what enables soft revocation: add an is_active column, check it in the dependency, and disabling an account takes effect on the next request rather than in fifteen minutes. The cost is one indexed primary-key read, close to free. Skipping it is a real optimisation with a usually unacceptable price.