Chapter 18: Authorization — Protecting Routes and Verifying Current User

Let’s Start With a Real Problem

Your blogging API is live. Users can log in and create posts. One day, a user named Priya notices something alarming: she can send a DELETE /posts/47 request — a post that belongs to a completely different user, someone she’s never even met — and it just… works. Your get_current_user dependency from Chapters 15 and 16 correctly confirmed who Priya is. But nothing ever checked whether Priya was actually allowed to delete post 47.

This is the difference between authentication (“who are you?”) and authorization (“what are you allowed to do?”). Chapter 16 gave us role-based authorization (regular user vs. admin). This chapter covers something just as common and just as important: ownership-based authorization — making sure a user can only modify their own data, not anyone else’s.


What You’ll Learn

  • The precise difference between authentication and authorization
  • How to check that the current user actually owns a resource before letting them modify or delete it
  • How to combine ownership checks with admin overrides (an admin can act on anyone’s data; a regular user only their own)
  • How to turn a repeated ownership check into a single, reusable FastAPI dependency
  • Theory + hands-on assignment questions to lock in what you’ve learned

Theory: Authentication vs. Authorization

AuthenticationAuthorization
Question it answers“Who is making this request?”“Is this specific person allowed to do this specific thing?”
Where we built itChapters 14–16 (get_current_user)Chapter 16 (roles) and this chapter (ownership)
Failure status code401 Unauthorized403 Forbidden

A request can be perfectly authenticated (a real, valid, logged-in user) and still be completely unauthorized for the specific action it’s attempting. Both checks matter, and they’re genuinely separate steps — get_current_user should only ever answer “who is this?”; a different piece of logic should answer “are they allowed to touch this particular row of data?”


Program 1: Basic Ownership Checks

Program Objective

Build a small, standalone FastAPI project where users can create posts, and only the post’s original author (or an admin) can update or delete it — with the ownership check written directly and explicitly inside each route.

Step-by-Step Implementation

Step 1: Create the project

uv init ownership-checks-demo
cd ownership-checks-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
ownership-checks-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
│   │   └── post.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   ├── token.py
│   │   └── post.py
│   ├── services/
│   │   ├── __init__.py
│   │   ├── user_service.py
│   │   └── post_service.py
│   └── routers/
│       ├── __init__.py
│       ├── users.py
│       ├── auth.py
│       └── posts.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 = "Ownership Checks 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

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_admin: Mapped[bool] = mapped_column(Boolean, default=False)

app/models/post.py

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

from app.db.database import Base


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str] = mapped_column(String(200))
    content: Mapped[str] = mapped_column(String(2000))
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

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_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/schemas/post.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field


class PostCreate(BaseModel):
    title: Annotated[str, Field(min_length=3, max_length=200)]
    content: Annotated[str, Field(min_length=1, max_length=2000)]


class PostPatch(BaseModel):
    title: Annotated[str | None, Field(default=None, min_length=3, max_length=200)]
    content: Annotated[str | None, Field(default=None, min_length=1, max_length=2000)]


class PostOut(BaseModel):
    id: int
    title: str
    content: str
    user_id: int

    model_config = ConfigDict(from_attributes=True)

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/services/post_service.py

from sqlalchemy.orm import Session

from app.models.post import Post
from app.schemas.post import PostCreate


def create_post(db: Session, post_in: PostCreate, owner_id: int) -> Post:
    new_post = Post(title=post_in.title, content=post_in.content, user_id=owner_id)
    db.add(new_post)
    db.commit()
    db.refresh(new_post)
    return new_post


def get_post_by_id(db: Session, post_id: int) -> Post | None:
    return db.get(Post, post_id)

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.")

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

app/routers/users.py

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

from app.db.database import get_db
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)

app/routers/posts.py

from fastapi import APIRouter, Depends, HTTPException, Response, 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.post import PostCreate, PostOut, PostPatch
from app.services.post_service import create_post, get_post_by_id

router = APIRouter(prefix="/posts", tags=["Posts"])


@router.post("/", response_model=PostOut, status_code=status.HTTP_201_CREATED)
def create_new_post(
    post_in: PostCreate,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    return create_post(db, post_in, owner_id=current_user.id)


@router.get("/{post_id}", response_model=PostOut)
def get_post(post_id: int, db: Session = Depends(get_db)):
    post = get_post_by_id(db, post_id)
    if post is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")
    return post


@router.patch("/{post_id}", response_model=PostOut)
def update_post(
    post_id: int,
    data: PostPatch,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    post = get_post_by_id(db, post_id)
    if post is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")

    # --- Authorization check: must be the owner, or an admin ---
    if post.user_id != current_user.id and not current_user.is_admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="You do not have permission to modify this post.",
        )

    update_data = data.model_dump(exclude_unset=True)
    for field_name, value in update_data.items():
        setattr(post, field_name, value)

    db.commit()
    db.refresh(post)
    return post


@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_post(
    post_id: int,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    post = get_post_by_id(db, post_id)
    if post is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")

    # --- Authorization check: must be the owner, or an admin ---
    if post.user_id != current_user.id and not current_user.is_admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="You do not have permission to delete this post.",
        )

    db.delete(post)
    db.commit()
    return Response(status_code=status.HTTP_204_NO_CONTENT)

app/main.py

from fastapi import FastAPI

from app.core.config import settings
from app.db.database import Base, engine
from app.models import post, user  # noqa: F401
from app.routers import auth, posts, 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(posts.router)


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

Detailed Code Explanation

  • GET /posts/{post_id} has no authorization check at all — deliberately. Reading a post is public; the only actions that need protecting are ones that change data. Not every route needs authorization — only apply it where it’s actually meaningful.
  • update_post and delete_post follow an identical three-step pattern:
    1. Fetch the post — if it doesn’t exist, 404. This must happen before any authorization check, otherwise you’d leak information about which IDs exist versus which ones you’re forbidden from touching.
    2. Check ownership — if post.user_id != current_user.id and not current_user.is_admin: raise 403. This single line is the entire authorization logic for this program: a regular user can only act on posts where user_id matches their own id; an admin bypasses this check entirely.
    3. Only then perform the actual update or delete.
  • Notice authorization always comes after authentication, but before the actual action. Depends(get_current_user) runs first (answering “who is this?”), and only once that succeeds do we reach the ownership check (answering “are they allowed to do this specific thing?”).
  • Also notice this program repeats the exact same three lines of ownership-check code in two different routes. That’s a code smell worth noticing — and it’s exactly what Program 2 fixes.

Execution & Testing

uv run fastapi dev app/main.py
  1. Register two users, e.g. alice and bob, and log in as each to get two separate tokens.
  2. As alice, create a post via POST /posts/ — note its id.
  3. As bob (using his own token), try PATCH /posts/{alice's post id} — confirm you get a 403 Forbidden.
  4. As alice, PATCH the same post — confirm it succeeds.
  5. As bob, try DELETE /posts/{alice's post id} — confirm 403 again.
  6. Test the admin override: manually set bob‘s is_admin to 1 directly in the database (sqlite3 app.db "UPDATE users SET is_admin = 1 WHERE username = 'bob';"), log in again as bob to get a fresh token, then retry DELETE /posts/{alice's post id} — confirm it now succeeds, since admins bypass the ownership check.

Program 2: A Reusable Ownership-Checking Dependency

Program Objective

Refactor the repeated “fetch post, check 404, check ownership” logic from Program 1 into a single, reusable FastAPI dependency, so every route that needs it can simply declare it as a parameter — following the same clean-dependency philosophy from Chapters 12 and 16.

Step-by-Step Implementation

Step 1: Create the project

uv init reusable-ownership-demo
cd reusable-ownership-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
reusable-ownership-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
│   │   └── post.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   ├── token.py
│   │   └── post.py
│   ├── services/
│   │   ├── __init__.py
│   │   ├── user_service.py
│   │   └── post_service.py
│   └── routers/
│       ├── __init__.py
│       ├── users.py
│       ├── auth.py
│       └── posts.py
├── pyproject.toml
└── uv.lock

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

Complete Working Source Code

Most files here are identical to Program 1. Only core/dependencies.py, routers/posts.py, and services/post_service.py change meaningfully — but every file is included below for a complete, standalone project.

app/core/config.py

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Reusable Ownership 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.post import Post
from app.models.user import User
from app.services.post_service import get_post_by_id
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_owned_post(
    post_id: int,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
) -> Post:
    """Fetches a post and verifies the current user may modify it (owner or admin)."""
    post = get_post_by_id(db, post_id)
    if post is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")

    if post.user_id != current_user.id and not current_user.is_admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="You do not have permission to modify this post.",
        )

    return post

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_admin: Mapped[bool] = mapped_column(Boolean, default=False)

app/models/post.py

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

from app.db.database import Base


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str] = mapped_column(String(200))
    content: Mapped[str] = mapped_column(String(2000))
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

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_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/schemas/post.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field


class PostCreate(BaseModel):
    title: Annotated[str, Field(min_length=3, max_length=200)]
    content: Annotated[str, Field(min_length=1, max_length=2000)]


class PostPatch(BaseModel):
    title: Annotated[str | None, Field(default=None, min_length=3, max_length=200)]
    content: Annotated[str | None, Field(default=None, min_length=1, max_length=2000)]


class PostOut(BaseModel):
    id: int
    title: str
    content: str
    user_id: int

    model_config = ConfigDict(from_attributes=True)

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/services/post_service.py

from sqlalchemy.orm import Session

from app.models.post import Post
from app.schemas.post import PostCreate, PostPatch


def create_post(db: Session, post_in: PostCreate, owner_id: int) -> Post:
    new_post = Post(title=post_in.title, content=post_in.content, user_id=owner_id)
    db.add(new_post)
    db.commit()
    db.refresh(new_post)
    return new_post


def get_post_by_id(db: Session, post_id: int) -> Post | None:
    return db.get(Post, post_id)


def apply_post_patch(db: Session, post: Post, data: PostPatch) -> Post:
    update_data = data.model_dump(exclude_unset=True)
    for field_name, value in update_data.items():
        setattr(post, field_name, value)
    db.commit()
    db.refresh(post)
    return post


def remove_post(db: Session, post: Post) -> None:
    db.delete(post)
    db.commit()

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.")

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

app/routers/users.py

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

from app.db.database import get_db
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)

app/routers/posts.py

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

from app.core.dependencies import get_current_user, get_owned_post
from app.db.database import get_db
from app.models.post import Post
from app.models.user import User
from app.schemas.post import PostCreate, PostOut, PostPatch
from app.services.post_service import apply_post_patch, create_post, remove_post

router = APIRouter(prefix="/posts", tags=["Posts"])


@router.post("/", response_model=PostOut, status_code=status.HTTP_201_CREATED)
def create_new_post(
    post_in: PostCreate,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    return create_post(db, post_in, owner_id=current_user.id)


@router.patch("/{post_id}", response_model=PostOut)
def update_post(
    data: PostPatch,
    post: Post = Depends(get_owned_post),
    db: Session = Depends(get_db),
):
    return apply_post_patch(db, post, data)


@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_post(post: Post = Depends(get_owned_post), db: Session = Depends(get_db)):
    remove_post(db, post)
    return Response(status_code=status.HTTP_204_NO_CONTENT)

app/main.py

from fastapi import FastAPI

from app.core.config import settings
from app.db.database import Base, engine
from app.models import post, user  # noqa: F401
from app.routers import auth, posts, 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(posts.router)


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

Detailed Code Explanation

  • get_owned_post is the whole point of this program — it combines everything Program 1 repeated inline: fetch the post (404 if missing), check ownership (403 if not owner/admin), and finally return the actual Post object if everything checks out. FastAPI treats this exactly like any other dependency.
  • Compare the two versions of update_post: Program 1’s version was around 15 lines and mixed three concerns together (fetching, authorizing, updating). Program 2’s version is 5 lines and does exactly one thing: apply the patch to an already-verified, already-fetched post. delete_post is even shorter.
  • post: Post = Depends(get_owned_post) — notice FastAPI automatically pulls post_id out of the URL path and feeds it into get_owned_post‘s own post_id: int parameter, even though update_post and delete_post never mention post_id directly in their own signatures anymore. FastAPI resolves this chain of dependencies (and their shared path parameters) automatically.
  • post_service.py also grew two small, focused functions — apply_post_patch and remove_post — completing the separation of concerns: authorization logic lives in the dependency, data-mutation logic lives in the service layer, and the router is left doing almost nothing but wiring the two together.
  • This is the exact same lesson from Chapter 12’s router-level dependencies, applied one level deeper: whenever the same check shows up in more than one route, that’s your signal to extract it into a dependency.

Execution & Testing

uv run fastapi dev app/main.py

Repeat the exact same test sequence from Program 1:

  1. Register alice and bob, log in as both.
  2. Create a post as alice.
  3. Try PATCH and DELETE as bob — confirm both fail with 403.
  4. Try PATCH and DELETE as alice — confirm both succeed (test them on two separate posts, since DELETE removes it).
  5. Promote bob to admin in the database, log in again, and confirm he can now modify alice‘s remaining post.

Every behavior is identical to Program 1 — proving that refactoring toward a reusable dependency changed how the code is organized, not what it does.


Tips & Instructions for Developers

  • ✅ Always separate “does this resource exist?” (404) from “is this user allowed to touch it?” (403) — check existence first, always.
  • ✅ Extract a repeated authorization check into a dependency the moment you notice it appearing in a second route — don’t wait for a third.
  • ✅ Let dependencies return the actual resource (like Post), not just a boolean — it saves your route from re-fetching data that was already loaded during the check.
  • ✅ Always design an “admin override” path deliberately, and test it explicitly — it’s very easy to build ownership logic that accidentally locks out admins too.
  • ✅ Keep ownership logic in one central place (a dependency or service function) so a future change to your permission rules doesn’t require hunting through every route.
  • ❌ Don’t perform the authorization check before confirming the resource exists — leaking “this ID would have been forbidden” information for IDs that don’t even exist is a subtle but real information leak.
  • ❌ Don’t trust any ID in the request body for ownership decisions — always compare against current_user.id from the verified token, never a user_id field a client could simply type into a request themselves.

Official FastAPI Documentation References


Theory Questions

  1. What’s the precise difference between authentication and authorization, and which status codes correspond to each failing?
  2. Why must the “does this resource exist?” check always happen before the “is this user allowed to modify it?” check?
  3. Why is get_owned_post allowed to depend on get_current_user, and what does FastAPI do with that chain automatically?
  4. Why is it dangerous to trust a user_id field sent in a request body for ownership decisions, instead of using current_user.id?
  5. What specific problem does extracting get_owned_post into its own dependency solve, compared to Program 1’s inline version?

Practical Assignment

  1. Apply the pattern to a new resource: Starting from Program 2’s project, add a Comment model (belonging to a Post, owned by a User), and build a get_owned_comment dependency following the same pattern, used to protect PATCH and DELETE routes for comments.
  2. Add a “read-only for others” rule: Starting from Program 2’s project, add a GET /posts/mine endpoint that returns only the current user’s own posts, using current_user.id to filter the query — a different, read-focused kind of authorization than the write-protection you’ve built so far.
  3. Break it on purpose: Starting from Program 1’s project (the inline version), deliberately swap the order of the two checks in update_post — perform the ownership check before fetching the post — and observe what happens when you request a post_id that doesn’t exist at all while not being an admin. Write down, in your own words, why the error you get back is misleading, and why the original order avoided it.

Summary

In this chapter, you learned:

  • The precise, practical difference between authentication and authorization
  • How to check resource ownership before allowing a user to update or delete data
  • How to combine ownership checks with an admin override
  • How to extract a repeated authorization check into a single, reusable FastAPI dependency that also hands back the already-verified resource

Scroll to Top