Index Ai Academy
Phase 8 · APIs · Web apps

Project: a FastAPI service with accounts

A session per request, bcrypt sign-ups, signed tokens, ownership checks and tests on a fresh database.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Most backend jobs are some version of this project: an API with a database behind it, accounts in front of it, and rules about who may see what. Each piece appeared on its own in earlier lessons (FastAPI routes, SQLAlchemy models, password hashing, signed tokens). This project wires them into one service for the trail club's trip planner, the way a real codebase does, and builds the habits that separate a demo from something you'd deploy: no secrets in the code, no passwords in responses, no one reading another member's trips, and tests that start from a clean database every time.

What you'll be able to do

  • Give every request its own database session with a dependency that always closes it
  • Sign members up with bcrypt and log them in with short-lived signed tokens
  • Find the caller with a get_current_user dependency, and only ever show them their own data
  • Test the whole service against a fresh in-memory database with dependency overrides

What it covers

  1. The service
  2. A database session per request
  3. Passwords and tokens
  4. Who is asking, and what may they see
  5. Close the hole: only your own trips
  6. Tests that start clean
Quick check
Members keep private trip lists, and an admin can see all of them. Where does "members only see their own" live?
  1. In the database password, which only members know
  2. In the API documentation, as a note for client developers
  3. In each query, filtered by the current user unless they're an admin
  4. In the client app, which hides other members' trips
Show the answer

In each query, filtered by the current user unless they're an admin

The rule has to run on the server, in every query that touches trips, with an exception for admins. Docs and client apps can't enforce anything, because a request can come from anywhere.

The service

Members sign up, log in, and keep a list of planned trips that only they can see; a club admin can see everyone's.

method and pathwho may call itwhat it does
POST /signupanyonecreates a member; 409 if the email is taken
POST /tokenanyonechecks email and password, returns a signed token; 401 if wrong
GET /mea logged-in memberwho the token belongs to
POST /trips, GET /tripsa logged-in memberadd a trip, list your trips (an admin sees all)
GET /trips/{id}, DELETE /trips/{id}the trip's owner, or an adminone trip; 404 for anyone else

In a real repository each layer gets its own file: models.py (database tables), schemas.py (the shapes that come in and go out), security.py (passwords and tokens), deps.py (the dependencies below) and main.py (the routes). Here each cell builds on the one before, so run them in order.

A database session per request

Every request should get its own database session, and that session must be closed when the request ends, even if the route raises an error. A FastAPI dependency written with yield does exactly this: the code before yield runs before the route, the route gets the yielded value, and the finally part runs afterward, always.

import asyncio
import httpx
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from sqlalchemy import ForeignKey, String, create_engine, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
from sqlalchemy.pool import StaticPool

class Base(DeclarativeBase):
    pass

class Member(Base):
    __tablename__ = "members"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String, unique=True)
    password_hash: Mapped[bytes]
    role: Mapped[str] = mapped_column(default="member")

class Trip(Base):
    __tablename__ = "trips"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    day: Mapped[str]
    owner_id: Mapped[int] = mapped_column(ForeignKey("members.id"))

def make_sessions(url="sqlite://"):
    # StaticPool keeps one connection, so an in-memory database lives as long as the engine
    engine = create_engine(url, connect_args={"check_same_thread": False}, poolclass=StaticPool)
    Base.metadata.create_all(engine)
    return sessionmaker(bind=engine)

SessionLocal = make_sessions()

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

class MemberOut(BaseModel):
    id: int
    email: str
    role: str

app = FastAPI()

@app.get("/members/count")
def count_members(db: Session = Depends(get_db)):
    return {"members": db.scalar(select(func.count()).select_from(Member))}

async def call(method, url, **kw):
    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
        return await client.request(method, url, **kw)

print(asyncio.run(call("GET", "/members/count")).json())

A few details matter. sessionmaker(bind=engine) is a factory: each call makes a fresh Session, just like the ORM lesson's Session(engine), so get_db can make one per request. check_same_thread=False lets a SQLite connection be used from another thread, which real FastAPI needs because it runs plain def routes in a pool of threads. StaticPool keeps a single connection open, because an in-memory SQLite database ("sqlite://") exists only while its connection does; on a real server the URL would point at PostgreSQL and the default pool would do. The count route asks the database to count (func.count()) instead of loading every row. And MemberOut is the shape the API will return for a member: it has no password_hash, so even a route that returns the database row can't leak the hash, once it declares response_model=MemberOut.

Quick check
This drives a yield dependency by hand, the way FastAPI does. What does it print?
def get_db():
    print("open")
    try:
        yield "db"
    finally:
        print("close")

steps = get_db()
db = next(steps)
print("route uses", db)
next(steps, None)
  1. open route uses db
  2. open route uses db close
  3. route uses db open close
  4. open close route uses db
Show the answer

open route uses db close

next() runs the function up to yield, printing open and handing over the session. The route uses it. The second next() resumes after yield, so the finally block prints close; the function then ends, and next's default None stops it raising StopIteration.

FastAPI does the same with get_db: it runs the dependency up to yield, passes the session to the route, and resumes the function once the route is done, so the finally block closes the session. If the route raises an error, FastAPI throws that error into the dependency at the yield, and finally still runs. That's why the try/finally is there: without it, every failed request would leave a session open, and on a busy server those open sessions pile up until the connection pool runs dry and every new request waits.

Quick check
A route returns the Member row from the database and has no response_model. What does the client receive?
  1. Only the id and email, since FastAPI hides fields that look like hashes automatically
  2. Every column FastAPI can serialize, including password_hash, unless a response model filters it
  3. Nothing: ORM rows can't be returned from a route without converting them first
  4. A 500 error every time, since the row isn't a plain dict that can become JSON
Show the answer

Every column FastAPI can serialize, including password_hash, unless a response model filters it

FastAPI converts what you return. Declaring response_model=MemberOut makes the output shape explicit, so fields like password_hash never leave the server, whatever the route returns.

Passwords and tokens

Signing up stores a bcrypt hash of the password, never the password itself, using a cost (the number of rounds) that makes each guess slow. Logging in checks the password with bcrypt.checkpw and hands back a token: the signed HS256 kind from the auth lesson, carrying the member's id (sub), role and an expiry about 30 minutes away.

The signing secret comes from the environment, not the source code. Code is shared, copied and committed; a secret written in it is a secret every reader of the repository has. The cell below falls back to a practice value only so it runs here.

import base64, hashlib, hmac, json, os, time
import bcrypt

SECRET = os.environ.get("TOKEN_SECRET", "practice-only-secret").encode()
ROUNDS = 4          # fast for the browser; real services use 12 or more

def hash_password(password):
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=ROUNDS))

def check_password(password, hashed):
    return bcrypt.checkpw(password.encode(), hashed)

def b64(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def make_token(member_id, role, minutes=30, now=None):
    now = time.time() if now is None else now
    head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64(json.dumps({"sub": str(member_id), "role": role, "exp": int(now + minutes * 60)}).encode())
    sig = b64(hmac.new(SECRET, f"{head}.{body}".encode(), hashlib.sha256).digest())
    return f"{head}.{body}.{sig}"

def read_token(token, now=None):
    """The token's claims, or None if it's malformed, forged or expired."""
    now = time.time() if now is None else now
    try:
        head, body, sig = token.split(".")
        good = b64(hmac.new(SECRET, f"{head}.{body}".encode(), hashlib.sha256).digest())
        if not hmac.compare_digest(sig.encode(), good.encode()):
            return None
        claims = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
    except (ValueError, json.JSONDecodeError):
        return None
    return claims if claims.get("exp", 0) > now else None

h = hash_password("pine-gap-ridge")
print(check_password("pine-gap-ridge", h), check_password("pine-gap-rigde", h))
t = make_token(7, "member")
print(read_token(t)["sub"], read_token(t[:-2] + "xx"), read_token(make_token(7, "member", minutes=-1)))
It runs right here in your browser, with nothing to install.

The last line shows the three outcomes that matter: a good token gives its claims; a tampered signature and an expired token both give None. A route never has to know which went wrong: either way the answer is 401.

Compared with the auth lesson's checklist, two checks are left out on purpose. This service never reads alg from the token: it always verifies with HS256, so a forged header can't switch the algorithm. And one service issues and reads its own tokens, so there's no audience to check; add aud when several services share a secret, and use a maintained library such as PyJWT in production. One more habit: a failed login gets the same 401 whether the email is unknown or the password is wrong, so the login form can't be used to test who's a member. That isn't airtight (signup's 409 says an email is taken, and skipping bcrypt for an unknown email answers faster), which is why real services also limit how often these routes can be called.

Try it now

Every protected route runs this chain before its own code; a failure at any step is a 401.

Who is asking, and what may they see

get_current_user is the dependency every protected route lists. It turns a token into a member, or stops the request with 401 and the header WWW-Authenticate: Bearer, which tells clients which kind of credential to send. OAuth2PasswordBearer(tokenUrl="token") reads the token from the Authorization header, and records in the API's generated docs that these routes need one.

Authorization is the next step, and the most common serious bug in real APIs is skipping it: a route that loads /trips/42 without checking that trip 42 belongs to the caller. Anyone logged in can then read anyone's data by counting through ids, a hole called an insecure direct object reference (IDOR). The fix is to filter by owner in the query itself, and to answer 404 rather than 403 for someone else's trip, so the API doesn't even confirm that the trip exists.

from fastapi.security import OAuth2PasswordBearer

oauth2 = OAuth2PasswordBearer(tokenUrl="token")

def get_current_user(token: str = Depends(oauth2), db: Session = Depends(get_db)) -> Member:
    claims = read_token(token)
    member = db.get(Member, int(claims["sub"])) if claims else None
    if member is None:
        raise HTTPException(401, "Not logged in", headers={"WWW-Authenticate": "Bearer"})
    return member

@app.get("/trips/{trip_id}")
def get_trip(trip_id: int, me: Member = Depends(get_current_user), db: Session = Depends(get_db)):
    trip = db.scalar(select(Trip).where(Trip.id == trip_id, Trip.owner_id == me.id))
    if trip is None:
        raise HTTPException(404, "Trip not found")
    return trip

Notice that owner_id == me.id sits inside the query: it isn't an afterthought checked once the trip is loaded, so a later edit can't easily drop it.

Quick check
Ana is logged in and requests GET /trips/17, which belongs to Bo. With the code above, what does she get, and why that status?
  1. 401: her token isn't valid for that trip, so she has to log in again as its owner
  2. 404: the query filters by owner, so for Ana trip 17 doesn't exist, and nobody learns it exists
  3. 403: she's logged in but not allowed to see it, and saying so plainly is clearer for her
  4. 200 with Bo's trip, since she's logged in and the trip id she asked for is a real one
Show the answer

404: the query filters by owner, so for Ana trip 17 doesn't exist, and nobody learns it exists

Her token is fine, so it isn't 401. Answering 403 would reveal that trip 17 exists; 404 treats someone else's trip exactly like a missing one, which leaks nothing.

Drill

Close the hole: only your own trips

The starter's trip routes check the token but not the owner, so any member can read or delete anyone's trip. Fix list_trips (a member sees only their own trips, an admin sees all) and delete_trip (404 unless the trip is the caller's; admins may delete any). The models, tokens and current_user are given; a tiny in-memory store stands in for the database so the fix is all you see.

from fastapi import Depends, FastAPI, Header, HTTPException

TRIPS = {1: {"name": "Fern Hollow loop", "owner": "ana"}, 2: {"name": "Red Mesa climb", "owner": "bo"}, 3: {"name": "Pine Gap walk", "owner": "ana"}}
TOKENS = {"t-ana": {"user": "ana", "role": "member"}, "t-bo": {"user": "bo", "role": "member"}, "t-cy": {"user": "cy", "role": "admin"}}

app = FastAPI()


def current_user(authorization: str = Header(default="")):
    user = TOKENS.get(authorization.removeprefix("Bearer "))
    if user is None:
        raise HTTPException(401, "Not logged in", headers={"WWW-Authenticate": "Bearer"})
    return user


@app.get("/trips")
def list_trips(me: dict = Depends(current_user)):
    return [{"id": i, **t} for i, t in TRIPS.items()]


@app.delete("/trips/{trip_id}", status_code=204)
def delete_trip(trip_id: int, me: dict = Depends(current_user)):
    if trip_id not in TRIPS:
        raise HTTPException(404, "Trip not found")
    del TRIPS[trip_id]
In the lesson, your code is checked as you write it.

Tests that start clean

A test that depends on what an earlier test left in the database passes or fails depending on the order you run them in, the most frustrating kind of flaky test. FastAPI's app.dependency_overrides fixes this: map the real get_db to a replacement, and every route that asks for get_db gets the replacement instead. Give each test a brand-new in-memory database and nothing leaks between them:

def fresh_app():
    sessions = make_sessions()                     # a new, empty in-memory database
    def test_db():
        db = sessions()
        try:
            yield db
        finally:
            db.close()
    app.dependency_overrides[get_db] = test_db
    return app

The routes don't change at all: they still say Depends(get_db). That's the payoff of getting the session from a dependency instead of a global: the same seam lets tests swap the database, and lets production swap SQLite for PostgreSQL. Remember to clear the overrides afterward (app.dependency_overrides.clear()) if other tests use the same app.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why is get_db written with yield and a finally block?

  1. So the session is closed after every request, even when the route raises
  2. Because FastAPI only accepts dependencies that are written as generators
  3. To make the database faster by reusing the same connection every time
  4. So all requests share one session, which keeps their changes together

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson