Chapter 16: OAuth2 with JWT Authentication | Secure Routes + Password Hashing

Let’s Start With a Real Problem

Open Swagger UI for the projects you built in Chapters 14 and 15. Look in the top-right corner. There’s no padlock. No “Authorize” button. Every single time you want to test a protected route, you have to manually copy a token, paste it into a header field, and repeat that for every request. That’s not how FastAPI’s own documentation demos work — and there’s a reason for that.

FastAPI has an official, built-in security flow — OAuth2PasswordBearer — that Swagger UI recognizes automatically. The moment you use it correctly, that padlock appears, and you can log in once and test every protected endpoint without touching headers manually again. This chapter rebuilds our authentication system using that exact official pattern, and then goes further — layering multiple levels of route protection, like “must be logged in” and “must be an admin.”


What You’ll Learn

  • What OAuth2PasswordBearer is, and why it’s the pattern FastAPI’s own docs are built around
  • How OAuth2PasswordRequestForm powers Swagger UI’s built-in “Authorize” button
  • How to rebuild login and route protection using this official flow
  • How to layer dependencies to build multiple levels of access control (logged in → active → admin)
  • Theory + hands-on assignment questions to lock in what you’ve learned

💡 Where this fits in: Chapter 14 issued tokens. Chapter 15 verified them using HTTPBearer. This chapter swaps that verification mechanism for FastAPI’s official OAuth2PasswordBearer, and adds role-based route protection on top. The underlying JWT concepts are the same ones you already know — what’s new here is the official plumbing and the layered security pattern.


Theory: What OAuth2PasswordBearer Actually Does

OAuth2PasswordBearer doesn’t perform authentication itself — its real job is telling FastAPI (and Swagger UI) where the login endpoint is, so the interactive docs can build a proper login form for you automatically.

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")

That tokenUrl="auth/login" is what makes the padlock icon appear next to every protected route in /docs. Click it, and Swagger UI shows a real username/password form — exactly like a real client app would present to a user.

For this to work, your login endpoint needs to accept data in a specific shape: OAuth2 spec-compliant form data (username and password fields), not a JSON body like we used in Chapters 14 and 15. FastAPI provides OAuth2PasswordRequestForm specifically for this:

def login(form_data: OAuth2PasswordRequestForm = Depends()):
    # form_data.username and form_data.password are available here

💡 A quick technical note: Parsing form data requires the python-multipart package. If you installed FastAPI with uv add fastapi --extra standard (as we have throughout this series), it’s already included — no extra install step needed.


Program 1: The Official OAuth2PasswordBearer Login Flow

Program Objective

Build a small, standalone FastAPI project using FastAPI’s official OAuth2PasswordBearer + OAuth2PasswordRequestForm pattern — complete with real password hashing — so that Swagger UI’s “Authorize” button works out of the box.

Step-by-Step Implementation

Step 1: Create the project

uv init oauth2-official-demo
cd oauth2-official-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"

Step 2: Create the project structure

mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
oauth2-official-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── security.py
│   │   └── dependencies.py
│   ├── db/
│   │   ├── __init__.py
│   │   └── database.py
│   ├── models/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── token.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       ├── users.py
│       └── auth.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/core/config.py

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "OAuth2 Official Demo"
    app_version: str = "1.0.0"
    database_url: str = "sqlite:///./app.db"

    # NOTE: Hardcoded placeholder secret for learning purposes only.
    # Chapter 17 covers moving secrets like this into a proper .env file.
    jwt_secret_key: str = "a-very-insecure-demo-secret-change-this"
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 30


settings = Settings()

app/core/security.py

from datetime import datetime, timedelta, timezone

from jose import JWTError, jwt
from passlib.context import CryptContext

from app.core.config import settings

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)


def create_access_token(subject: str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
    to_encode = {"sub": subject, "exp": expire}
    return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_token(token: str) -> dict:
    try:
        return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
    except JWTError as exc:
        raise ValueError("Invalid or expired token.") from exc

app/core/dependencies.py

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session

from app.core.security import decode_token
from app.db.database import get_db
from app.models.user import User
from app.services.user_service import get_user_by_username

# tokenUrl points Swagger UI at our login endpoint, enabling the "Authorize" button.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")


def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db),
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials.",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = decode_token(token)
    except ValueError:
        raise credentials_exception

    username = payload.get("sub")
    if username is None:
        raise credentials_exception

    user = get_user_by_username(db, username)
    if user is None:
        raise credentials_exception

    return user

app/db/database.py

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

from app.core.config import settings

engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})

SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

app/models/user.py

from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column

from app.db.database import Base


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    hashed_password: Mapped[str] = mapped_column(String(255))
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool

    model_config = ConfigDict(from_attributes=True)

app/schemas/token.py

from pydantic import BaseModel


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

app/services/user_service.py

from sqlalchemy.orm import Session

from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate


def create_user(db: Session, user_in: UserCreate) -> User:
    new_user = User(
        username=user_in.username,
        email=user_in.email,
        hashed_password=hash_password(user_in.password),
    )
    db.add(new_user)
    db.commit()
    db.refresh(new_user)
    return new_user


def get_user_by_username(db: Session, username: str) -> User | None:
    return db.query(User).filter(User.username == username).first()

app/routers/auth.py

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import TokenResponse
from app.services.user_service import get_user_by_username

router = APIRouter(prefix="/auth", tags=["Auth"])


@router.post("/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    user = get_user_by_username(db, 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.",
        )

    access_token = create_access_token(subject=user.username)
    return TokenResponse(access_token=access_token)

app/routers/users.py

from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session

from app.core.dependencies import get_current_user
from app.db.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
    return create_user(db, user_in)


@router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_user)):
    return current_user

app/main.py

from fastapi import FastAPI

from app.core.config import settings
from app.db.database import Base, engine
from app.models import user  # noqa: F401
from app.routers import auth, users

Base.metadata.create_all(bind=engine)

app = FastAPI(title=settings.app_name, version=settings.app_version)

app.include_router(users.router)
app.include_router(auth.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": f"Welcome to {settings.app_name}"}

Detailed Code Explanation

  • OAuth2PasswordBearer(tokenUrl="auth/login") replaces Chapter 15’s HTTPBearer(). Functionally, it still extracts a Bearer token from the Authorization header — but because it also carries the tokenUrl, FastAPI can generate the correct OpenAPI security scheme, which is exactly what makes Swagger UI render its “Authorize” button and know which endpoint to call when you log in through it.
  • OAuth2PasswordRequestForm = Depends() in the login route replaces our earlier LoginRequest JSON schema. FastAPI automatically expects application/x-www-form-urlencoded data with username and password fields — the exact shape the OAuth2 password flow specifies, and the exact shape Swagger UI’s “Authorize” popup submits when you type your credentials into it.
  • Everything else — verify_password, create_access_token, get_current_user‘s core logic — is functionally identical to Chapter 15. The mechanism for extracting the token changed; the verification logic itself didn’t need to.
  • get_current_user still returns a real User object looked up from the database, keeping the same safety guarantee from Chapter 15: a deleted user’s old token stops working immediately, even if it hasn’t technically expired.

Execution & Testing

uv run fastapi dev app/main.py
  1. Open http://127.0.0.1:8000/docs. Register a user via POST /users/register.
  2. Look at the top-right corner of the page — you should now see a green “Authorize” button with a padlock icon. Click it.
  3. In the popup, enter your username and password (leave client_id, client_secret, and scopes blank), then click Authorize, then Close.
  4. Notice the padlock icons next to every protected route are now shown as “locked” (authorized). Call GET /users/me directly — you won’t need to paste any token manually. Swagger UI now automatically attaches your token to every request for the rest of your session.
  5. Click Authorize again and then Logout to clear the session, then try GET /users/me again — confirm it now fails with 401, proving the automatic authorization really was doing the work.

Program 2: Layered Secure Routes (User → Active User → Admin)

Program Objective

Extend the project with role-based access control — building a chain of dependencies where a route can require “must be logged in,” “must be logged in and active,” or “must be logged in, active, and an admin,” each layer building on the one before it.

Step-by-Step Implementation

Step 1: Create the project

uv init layered-security-demo
cd layered-security-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"

Step 2: Create the project structure

mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
layered-security-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── security.py
│   │   └── dependencies.py
│   ├── db/
│   │   ├── __init__.py
│   │   └── database.py
│   ├── models/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── token.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       ├── users.py
│       ├── auth.py
│       └── admin.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/core/config.py

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Layered Security Demo"
    app_version: str = "1.0.0"
    database_url: str = "sqlite:///./app.db"

    jwt_secret_key: str = "a-very-insecure-demo-secret-change-this"
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 30


settings = Settings()

app/core/security.py

from datetime import datetime, timedelta, timezone

from jose import JWTError, jwt
from passlib.context import CryptContext

from app.core.config import settings

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)


def create_access_token(subject: str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
    to_encode = {"sub": subject, "exp": expire}
    return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_token(token: str) -> dict:
    try:
        return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
    except JWTError as exc:
        raise ValueError("Invalid or expired token.") from exc

app/core/dependencies.py

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session

from app.core.security import decode_token
from app.db.database import get_db
from app.models.user import User
from app.services.user_service import get_user_by_username

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")


def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db),
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials.",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = decode_token(token)
    except ValueError:
        raise credentials_exception

    username = payload.get("sub")
    if username is None:
        raise credentials_exception

    user = get_user_by_username(db, username)
    if user is None:
        raise credentials_exception

    return user


def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
    if not current_user.is_active:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="This account has been deactivated.",
        )
    return current_user


def require_admin(current_user: User = Depends(get_current_active_user)) -> User:
    if not current_user.is_admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="This action requires admin privileges.",
        )
    return current_user

app/db/database.py

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

from app.core.config import settings

engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})

SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

app/models/user.py

from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column

from app.db.database import Base


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    hashed_password: Mapped[str] = mapped_column(String(255))
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    is_admin: Mapped[bool] = mapped_column(Boolean, default=False)

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool
    is_admin: bool

    model_config = ConfigDict(from_attributes=True)

app/schemas/token.py

from pydantic import BaseModel


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

app/services/user_service.py

from sqlalchemy.orm import Session

from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate


def create_user(db: Session, user_in: UserCreate, is_admin: bool = False) -> User:
    new_user = User(
        username=user_in.username,
        email=user_in.email,
        hashed_password=hash_password(user_in.password),
        is_admin=is_admin,
    )
    db.add(new_user)
    db.commit()
    db.refresh(new_user)
    return new_user


def get_user_by_username(db: Session, username: str) -> User | None:
    return db.query(User).filter(User.username == username).first()

app/routers/auth.py

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import TokenResponse
from app.services.user_service import get_user_by_username

router = APIRouter(prefix="/auth", tags=["Auth"])


@router.post("/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    user = get_user_by_username(db, 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.",
        )

    access_token = create_access_token(subject=user.username)
    return TokenResponse(access_token=access_token)

app/routers/users.py

from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session

from app.core.dependencies import get_current_active_user
from app.db.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
    return create_user(db, user_in)


@router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_active_user)):
    return current_user

app/routers/admin.py

from fastapi import APIRouter, Depends

from app.core.dependencies import require_admin
from app.models.user import User

router = APIRouter(prefix="/admin", tags=["Admin"], dependencies=[Depends(require_admin)])


@router.get("/stats")
def get_admin_stats():
    return {"total_users": 128, "total_products": 42}


@router.get("/whoami")
def admin_whoami(current_user: User = Depends(require_admin)):
    return {"username": current_user.username, "role": "admin"}

app/main.py

from fastapi import FastAPI

from app.core.config import settings
from app.db.database import Base, engine
from app.models import user  # noqa: F401
from app.routers import admin, auth, users

Base.metadata.create_all(bind=engine)

app = FastAPI(title=settings.app_name, version=settings.app_version)

app.include_router(users.router)
app.include_router(auth.router)
app.include_router(admin.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": f"Welcome to {settings.app_name}"}

Detailed Code Explanation

  • User model gains a second flag, is_admin, alongside the existing is_active.
  • core/dependencies.py now defines three dependencies that build on each other, each one deeper than the last:
    • get_current_user — the base layer: is this a valid, known user at all?
    • get_current_active_user(current_user: User = Depends(get_current_user)) — takes a dependency on another dependency. FastAPI resolves get_current_user first, then passes its result in here, where we add one more check: is this account active?
    • require_admin(current_user: User = Depends(get_current_active_user)) — builds on that, adding the final check: is this user an admin?
  • This is called dependency chaining, and it’s a genuinely powerful FastAPI pattern: each layer only needs to worry about its own single responsibility, and FastAPI automatically resolves the whole chain in the correct order for you.
  • routers/admin.py demonstrates two ways to apply this: dependencies=[Depends(require_admin)] at the router level protects every route in the file with zero repetition (the pattern from Chapter 12), while admin_whoami also injects current_user: User = Depends(require_admin) directly into its parameters, for routes that need to actually use the current user’s data, not just gate access to it.
  • user_service.create_user(..., is_admin: bool = False) defaults every new registration to a regular, non-admin account — a sensible, secure default. There’s no public endpoint for granting admin status in this demo; in a real project, that would be a carefully guarded action, often performed directly in a database console or by another already-verified admin.

Execution & Testing

uv run fastapi dev app/main.py
  1. Register a regular user via POST /users/register, then log in and click Authorize in Swagger UI, exactly as in Program 1.
  2. Call GET /users/me — confirm it works, and note is_admin: false in the response.
  3. Call GET /admin/stats — confirm you get a 403 Forbidden with "This action requires admin privileges.", even though you’re fully logged in.
  4. Promote yourself to admin directly in the database (for this learning exercise only): run sqlite3 app.db "UPDATE users SET is_admin = 1 WHERE username = 'your_username';" in your terminal.
  5. In Swagger UI, click Authorize → Logout, then log in again (tokens don’t automatically refresh their contents, so you need a fresh login to pick up the change — though in this simple version the check happens against the live database record anyway).
  6. Call GET /admin/stats again — confirm you now get a 200 with the stats data.

Tips & Instructions for Developers

  • ✅ Use OAuth2PasswordBearer + OAuth2PasswordRequestForm for any project where you want Swagger UI’s “Authorize” button to work — it’s a small change with a big developer-experience payoff.
  • ✅ Chain dependencies for layered access control (get_current_user → get_current_active_user → require_admin) instead of writing repeated if checks inside every route.
  • ✅ Default new accounts to the least privileged role possible (is_admin=False) — grant elevated access explicitly and deliberately, never by default.
  • ✅ Use 403 Forbidden (not 401 Unauthorized) when a user is correctly authenticated but lacks permission for a specific action — the two mean different things, and mixing them up confuses API consumers.
  • ✅ Keep each dependency layer focused on exactly one check — it keeps the chain easy to read, test, and reorder if your permission model grows more complex later.
  • ❌ Don’t build a public API endpoint that lets any user grant themselves (or others) admin rights — that kind of action needs much stronger safeguards than a simple PATCH request.
  • ❌ Don’t skip testing the “should be denied” paths — a permissions system that’s never tested against an unauthorized user is a permissions system you can’t actually trust.

Official FastAPI Documentation References


Theory Questions

  1. What is the actual purpose of the tokenUrl parameter in OAuth2PasswordBearer, given that token verification itself doesn’t call that URL?
  2. Why does OAuth2PasswordRequestForm expect form data instead of a JSON body?
  3. What is “dependency chaining,” and what problem does it solve compared to writing repeated permission checks in every route?
  4. Why is 403 Forbidden the more accurate status code than 401 Unauthorized for an authenticated user who lacks the right permissions?
  5. Why does create_user default is_admin to False, and why is there no API endpoint in this demo for granting admin rights?

Practical Assignment

  1. Add a third tier: Starting from Program 2’s project, add a is_superadmin flag and a require_superadmin dependency that builds on require_admin, following the same chaining pattern.
  2. Protect a single route, not a whole router: Starting from Program 2’s project, add a new DELETE /users/{user_id} endpoint to the users router that requires require_admin directly on that one route (not the whole router), while GET /users/me continues to only require get_current_active_user.
  3. Write the chain from memory: Without looking back at the code, write out (in comments or on paper) the full dependency chain from require_admin down to the raw incoming request — naming every dependency involved and what single check each one adds.

Summary

In this chapter, you learned:

  • How OAuth2PasswordBearer and OAuth2PasswordRequestForm power Swagger UI’s official “Authorize” flow
  • How to rebuild login and token verification using FastAPI’s official, spec-compliant pattern
  • How to chain dependencies to build layered access control — logged in, active, and admin-only routes
  • Why 403 and 401 mean genuinely different things, and when to use each
Scroll to Top