1. Secure Password Hashing
A password column should never contain plaintext, and it should never contain anything reversible either — no encryption with a key you hold, because a key that can decrypt is a key that can also be stolen. Instead you store the output of a one-way hash function: something trivial to compute in one direction (password → digest) and computationally infeasible to reverse (digest → password). If your database ever leaks, an attacker gets a pile of digests, not credentials — and because people reuse passwords across sites, a plaintext leak on your side becomes a breach everywhere else that user has an account.
Not every hash function is suitable for passwords. SHA-256 is
designed to be fast — great for checksumming a file, terrible for
passwords, because "fast" also means an attacker with a GPU can try billions of
guesses per second against a leaked digest. bcrypt is designed
to be slow and tunable: it has a configurable cost factor
(work factor) that exponentially increases the compute required per hash, so you
can keep raising it as hardware gets faster without changing your code.
A salt is a random value generated per password and mixed in
before hashing. Without one, two users with the password "password123"
would produce the exact same digest, which lets an attacker precompute a table of
common password hashes once (a "rainbow table") and look up matches instantly
across every leaked database they find. With a unique salt per password, the same
input produces a different digest every time, so precomputation stops working —
the attacker has to brute-force each hash individually. bcrypt generates this salt
for you automatically and stores it as part of the output string itself, so you
never manage salts by hand.
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
pwd_context.hash generates the salt, applies bcrypt at the configured
cost factor, and returns one self-contained string (algorithm identifier, cost,
salt and digest all encoded together) that you store as-is:
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
Don't reach for "a hash function" generically — the right one depends on the threat model. SHA-256 is right for verifying a file wasn't corrupted; bcrypt (or argon2/scrypt) is right for passwords, precisely because it's slow. Using a fast hash for passwords is one of the most common real-world security mistakes.
2. The OAuth2 Password Flow
OAuth2 is a broad authorization framework with several distinct "grant types" —
different flows for different trust situations. The one this week uses is the
Password Grant (formally, Resource Owner Password Credentials):
a client collects a username and password directly and exchanges them with your
own API for an access token. FastAPI ships two dependencies built specifically
around this flow: OAuth2PasswordRequestForm, which parses the
standard OAuth2 form fields (username, password,
grant_type) out of a application/x-www-form-urlencoded
request body, and OAuth2PasswordBearer, which extracts a bearer
token from the Authorization header on protected routes and tells
FastAPI's interactive docs where to send login requests.
This is not the same shape as "Login with Google." That's the Authorization Code grant: the user is redirected to Google, authenticates and consents there, and your backend receives a one-time code it exchanges server-to-server for a token — your application never sees the user's Google password at all. That indirection exists specifically because a third party owns the credential and needs to keep it away from every app that wants to use it. The password grant skips all of that because, in this course, you are the resource owner's application and the identity provider at the same time — there's no third-party credential to protect.
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
router = APIRouter()
@router.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict[str, str]:
user = get_user_by_username(form_data.username)
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(user.id)
return {"access_token": access_token, "token_type": "bearer"}
Notice the endpoint returns the same generic error for "no such user" and "wrong password." Distinguishing them in the response would let an attacker enumerate valid usernames by watching which error comes back — the kind of detail that belongs in server logs, not in the HTTP response body.
OAuth2PasswordBearer doesn't call any external provider — it's a thin dependency that reads the Authorization: Bearer <token> header and raises a 401 if it's missing. It's named after the spec it implements the shape of, not because it talks to anything outside your own API.
3. Short-Lived Signed Access Tokens
A JSON Web Token is three base64url-encoded segments joined by
dots: header.payload.signature. The header names the signing
algorithm; the payload holds claims — arbitrary key/value data
such as sub (subject, conventionally the user identifier) and
exp (expiration, a Unix timestamp); the signature is computed over
the header and payload using a secret key. Critically, a JWT is
signed, not encrypted — anyone can base64-decode the payload and
read it (paste one into jwt.io and see for yourself), but they cannot alter it
without invalidating the signature, because they don't hold the secret used to
produce it.
from datetime import datetime, timedelta, timezone
import jwt
def create_access_token(user_id: int) -> str:
payload = {"sub": str(user_id), "exp": datetime.now(timezone.utc) + timedelta(minutes=15)}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
HS256 is a symmetric algorithm — the same secret both signs and
verifies tokens, which is fine as long as one backend does both. (If several
independent services needed to verify tokens without being trusted to issue new
ones, you'd switch to RS256: a private key signs, a public key
verifies, and only the issuing service ever holds the private key.) The 15-minute
exp is deliberate — it bounds how long a stolen token stays useful.
jwt.decode checks the signature and the expiration in one
call, so verifying "is this token still good" never requires a database lookup.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise credentials_error
except jwt.InvalidTokenError:
raise credentials_error
user = get_user_by_id(int(payload["sub"]))
if user is None:
raise credentials_error
return user
jwt.decode raises ExpiredSignatureError specifically
when exp has passed, and the broader InvalidTokenError
(its superclass, which also covers a bad signature, malformed token, or wrong
algorithm) for everything else that makes a token untrustworthy. Both collapse to
the same 401 here — the caller doesn't need to know why their token was
rejected, only that they need to authenticate again.
A short-lived, stateless JWT can't be revoked early — there's no server-side session to delete, so a token is valid until its exp no matter what. That's exactly why real systems pair it with a separate, revocable refresh token for renewing access without re-authenticating — the subject of next week.
4. Hands-on Exercise
Add registration, login and a protected profile route
Wire this week's pieces into a real vertical slice: a user can register, log in and receive a JWT, then use that token to fetch their own profile.
Requirements:
- Add a
Usertable/model withid, a uniqueusername, andhashed_password— no plaintext password column anywhere. - Build
POST /registeraccepting a username and password, hashing the password withpwd_context.hashbefore saving, and returning400if the username is already taken. - Build
POST /tokenusingOAuth2PasswordRequestForm, verifying the submitted password against the stored hash withpwd_context.verify, and returning{"access_token": ..., "token_type": "bearer"}on success or401with a generic "Incorrect username or password" message on failure. - Implement
create_access_token(user_id)issuing a JWT with asuband a 15-minuteexp, signed with a secret loaded from settings/environment — never hardcoded in source. - Implement
get_current_useras anOAuth2PasswordBearerdependency that decodes the token, catches bothExpiredSignatureErrorandInvalidTokenError, and returns401for either plus for asubthat no longer matches a real user. - Add
GET /users/medepending onget_current_user, returning only the current user'sidandusername— never thehashed_passwordfield.
Test /register and /token with curl or a REST client first, then open /docs and click Authorize — Swagger UI reads your OAuth2PasswordBearer(tokenUrl="token") configuration and handles the whole login exchange for you, attaching the resulting bearer token to every request you try from the docs page afterward.
5. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why must JWT payloads never contain secrets?
Why must JWT payloads never contain secrets?
A JWT is signed, not encrypted — the header and payload are just base64url-encoded, not enciphered, so anyone holding the token can decode and read every claim inside it without knowing the signing secret. The signature only guarantees that the content hasn't been tampered with since it was issued; it does nothing to hide that content. Anything sensitive (passwords, raw card numbers, internal secrets) placed in a claim is effectively public to whoever can get their hands on the token.
Q2
Why use bcrypt instead of a fast general-purpose hash like SHA-256 for passwords, and what problem does the salt specifically solve?
Why use bcrypt instead of a fast general-purpose hash like SHA-256 for passwords, and what problem does the salt specifically solve?
bcrypt is deliberately slow and has a tunable cost factor, which makes brute-forcing a leaked digest computationally expensive even with GPU clusters, whereas SHA-256 is engineered to be fast — exactly the wrong property for a password hash, since fast means an attacker can try billions of candidate passwords per second. The salt is a random value mixed in before hashing so that two users with the identical password get completely different digests; without it, an attacker could precompute a table of hashes for common passwords once (a rainbow table) and match it instantly against any leaked database, and could spot which accounts share a password just by comparing hash values.
Q3
What's the practical difference between the password flow you built this week and a full OAuth2 authorization-code flow with a provider like Google?
What's the practical difference between the password flow you built this week and a full OAuth2 authorization-code flow with a provider like Google?
In the password flow, your own client collects the username and password directly and exchanges them with your own API for a token — appropriate because your application is simultaneously the resource owner's client and the identity provider, so there's no third-party credential that needs protecting. In the authorization-code flow, the user is redirected to the third-party provider, authenticates there, and your backend never sees that password at all — it receives a one-time code it exchanges server-to-server for a token. That extra indirection exists specifically to keep the user's provider credentials out of your application entirely.
Q4
Your get_current_user dependency raises the same 401 for an expired token, a tampered token, and a token whose user no longer exists. Why not return a different error message for each case?
Your get_current_user dependency raises the same 401 for an expired token, a tampered token, and a token whose user no longer exists. Why not return a different error message for each case?
Returning distinct messages would tell an attacker exactly which part of a forged or stolen token failed — whether the signature was invalid, the token had merely expired, or the embedded user id no longer resolves — information that helps them refine an attack rather than give up. Collapsing all three into one generic "could not validate credentials" response leaks nothing useful to the caller; the specifics belong in server-side logs, where you can still investigate and alert on repeated failures.