Week 6: Authentication with OAuth2 & JWT

Every endpoint you've built through Week 5 trusts whoever calls it — there's no notion of a logged-in user anywhere in the stack yet. This week closes that gap: you'll hash passwords properly so a leaked database never hands out anything usable, wire up FastAPI's OAuth2 password flow so a client can trade a username and password for a bearer token, and issue short-lived JSON Web Tokens that your endpoints can verify without a database round trip on every request. By the end you'll have a working registration flow, a /token login endpoint, and a /users/me route that only responds to a request carrying a valid, unexpired token.

Module 5 of 22 Week 6 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Hash and verify passwords with passlib/bcrypt instead of ever touching plaintext
  • Implement a /token login endpoint using FastAPI's OAuth2 password flow
  • Issue, decode and verify short-lived JWT access tokens inside a reusable dependency

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.

security.py
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:

models.py
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))
Different hash for a different job

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.

routers/auth.py
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.

"OAuth2" is just a naming convention here

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.

security.py
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.

dependencies.py
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.

The tradeoff you're accepting

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

Hands-on

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:

  1. Add a User table/model with id, a unique username, and hashed_password — no plaintext password column anywhere.
  2. Build POST /register accepting a username and password, hashing the password with pwd_context.hash before saving, and returning 400 if the username is already taken.
  3. Build POST /token using OAuth2PasswordRequestForm, verifying the submitted password against the stored hash with pwd_context.verify, and returning {"access_token": ..., "token_type": "bearer"} on success or 401 with a generic "Incorrect username or password" message on failure.
  4. Implement create_access_token(user_id) issuing a JWT with a sub and a 15-minute exp, signed with a secret loaded from settings/environment — never hardcoded in source.
  5. Implement get_current_user as an OAuth2PasswordBearer dependency that decodes the token, catches both ExpiredSignatureError and InvalidTokenError, and returns 401 for either plus for a sub that no longer matches a real user.
  6. Add GET /users/me depending on get_current_user, returning only the current user's id and username — never the hashed_password field.
Hint

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?

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?

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?

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?

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.