Modern backend — APIs, auth, databases
FastAPI or Hono. REST, JSON, JWT, sessions, password hashing — without copying StackOverflow. The minimum competent backend.
Prerequisites
02.6
Stack
Python (FastAPI) — primary pathNode (Hono) — alternative pathPostgres 16uv or pnpmargon2-cffi (Python) or @node-rs/argon2 (Node)JWT or session cookies (decision in module)pytest or vitest
By the end of this module
- Design a REST API that other engineers can read without explanation.
- Validate every input at the boundary with Pydantic (or Zod) and return structured errors.
- Hash passwords correctly with argon2 and decide between sessions and JWTs on purpose.
- Write integration tests that exercise the real database in a test container.
- Ship a TODO API to a real URL with 80 percent test coverage and basic rate limiting.
The backend is where the frontend’s lies meet the database’s truth. It’s where validation has to be real, where errors have to be both precise and safe, and where one rolled-your-own auth system can become a CVE on Hacker News overnight. The difference between a competent backend and a portfolio backend is mostly discipline at the boundaries — input in, output out — not architectural cleverness.
This module is the minimum competent backend, with strong opinions where the stakes are real. The primary path is Python plus FastAPI. If you’d rather use Node, the Hono path is functionally identical — same shapes, same trade-offs — and noted at each step.
The single most load-bearing opinion in this module: auth is the part you do not roll yourself. You will implement password hashing and session handling once for understanding, then in real projects you will reach for Auth.js, Clerk, WorkOS, or Supabase Auth. Implementing your own OAuth, your own MFA, your own password reset is how 23-year-old you ships an account-takeover bug to production.
Set up
Pick one path. The rest of the module assumes it.
Python path:
mkdir todo-api && cd todo-api
uv venv .venv && source .venv/bin/activate
uv pip install fastapi 'uvicorn[standard]' pydantic pydantic-settings \
sqlalchemy psycopg2-binary alembic argon2-cffi python-jose[cryptography] \
pytest httpx slowapi
git init && echo ".venv/\n.env\n__pycache__/" >> .gitignore
Node path:
mkdir todo-api && cd todo-api
pnpm init
pnpm add hono @hono/node-server zod drizzle-orm postgres @node-rs/argon2 jose
pnpm add -D typescript tsx vitest @types/node drizzle-kit
git init && echo "node_modules/\n.env\ndist/" >> .gitignore
You also need a Postgres locally. The simplest way, regardless of path:
docker run --name todo-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16
If you don’t have Docker, install Postgres 16 from your package manager. Either works.
Read these first
Four references, in order, then stop:
- FastAPI — Tutorial. docs · 2 hrs · the canonical walkthrough. Skip the Node path equivalent if you’re on Python.
- OWASP — Authentication cheat sheet. post · 30 min · the bar you are aiming above, not below.
- Lucia author — A guide to (sessions vs JWT). post · 20 min · clearest writing on the trade-offs.
- PostgreSQL docs — Tutorial: Advanced features. docs · 30 min · views, transactions, foreign keys. Module 03.4 goes deeper.
Resist the urge to also read every microservices article on Medium. You are building a monolith on purpose.
Step 1 — REST without dogma
REST is a set of conventions, not a religion. The conventions that matter:
- Nouns for resources (
/todos), not verbs (/getTodos). - HTTP methods carry intent:
GETis safe,POSTcreates,PUTreplaces,PATCHupdates,DELETEdeletes. - Status codes mean what they say: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthenticated, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable, 429 Too Many Requests, 500 Server Error.
- One JSON envelope shape across the API. Pick it once, never deviate.
A reasonable envelope:
{ "data": { /* ... */ }, "error": null }
{ "data": null, "error": { "code": "NOT_FOUND", "message": "Todo 42 does not exist." } }
The conventions you can ignore unless you have a reason: HATEOAS (you don’t need it), versioning in headers (URL versioning is fine), JSON:API (overkill for any project under 50 endpoints). When you outgrow REST, the next thing to reach for is tRPC if your client and server are both TypeScript, or GraphQL if you have many heterogeneous clients. Not before.
Step 2 — Validation at the boundary
Trust nothing the network sends you. Validate every request body, every query param, every path param.
# app/schemas.py
from pydantic import BaseModel, Field
from datetime import datetime
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
due: datetime | None = None
class TodoOut(BaseModel):
id: int
title: str
done: bool
due: datetime | None
created_at: datetime
# app/main.py
from fastapi import FastAPI, HTTPException, Depends
from .schemas import TodoCreate, TodoOut
from .db import get_session
app = FastAPI()
@app.post("/todos", response_model=TodoOut, status_code=201)
def create_todo(payload: TodoCreate, session = Depends(get_session)):
todo = Todo(title=payload.title, due=payload.due)
session.add(todo); session.commit(); session.refresh(todo)
return todo
If a request body is invalid, FastAPI returns 422 with a structured error before your handler runs. This is non-negotiable security hygiene — every “they passed in unexpected JSON and our backend crashed” bug is a missing schema.
Node equivalent uses Zod with Hono’s zValidator — same shape, same idea.
Step 3 — Passwords, the only way
Use argon2id. Full stop. Not bcrypt (still common, no longer the recommendation). Not PBKDF2. Definitely not sha256 with a salt.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # sane defaults — argon2id, ~64MB memory cost
def hash_password(pw: str) -> str:
return ph.hash(pw)
def verify_password(pw: str, hashed: str) -> bool:
try:
ph.verify(hashed, pw)
return True
except VerifyMismatchError:
return False
Three rules to internalize:
| Rule | Why |
|---|---|
| Never log passwords or hashes | obvious in retrospect, easy to do by accident with broad request loggers |
| Never write a custom hash function | every “I’ll just sha256 plus salt” implementation has been broken |
Use the library’s needs_rehash() after verify | when you raise the cost factor next year, existing users get rehashed on next login |
Step 4 — Sessions vs JWTs, with a verdict
The honest answer to “JWT or sessions” is “sessions, almost always.” Here’s the trade-off table you actually need:
| Concern | Server-side session (cookie holds an opaque ID) | JWT (cookie or Authorization header holds the claims) |
|---|---|---|
| Revocation | trivial (delete the row) | hard (need a denylist, which is just a session table again) |
| Storage | one row per active session | nothing on the server |
| Cross-domain | works with cookies + correct CORS | popular for cross-domain APIs |
| Token size | tiny (random ID) | hundreds of bytes per request |
| Common failure mode | cookie misconfigured | secret leaked or alg=none accepted |
When a JWT is right: cross-service, short-lived API tokens between backends. When sessions are right: 95 percent of web apps with one frontend and one backend.
Implement sessions for this module:
import secrets
from datetime import datetime, timedelta
def create_session(user_id: int, session) -> str:
sid = secrets.token_urlsafe(32)
session.add(Session(id=sid, user_id=user_id,
expires_at=datetime.utcnow() + timedelta(days=30)))
session.commit()
return sid
Set the cookie correctly:
response.set_cookie(
"sid", sid,
httponly=True, # JS cannot read it
secure=True, # HTTPS only (off only on localhost)
samesite="lax", # blocks most CSRF
max_age=60 * 60 * 24 * 30,
path="/",
)
HttpOnly blocks XSS-stolen cookies. Secure enforces HTTPS. SameSite=Lax blocks the obvious CSRF vectors without breaking normal navigation. Get this cookie wrong and your auth is theater.
Step 5 — CORS, rate limiting, structured errors
CORS is not security, it is a same-origin opt-out. You configure it on the server, you list specific origins, you do not use * in production:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourdomain.com"],
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["content-type", "authorization"],
allow_credentials=True,
)
Rate limit every public endpoint, especially auth ones. slowapi (FastAPI) and hono-rate-limiter (Hono) both give you something usable in 10 lines. A reasonable starting point: 5 login attempts per minute per IP, 60 reads per minute per user, 30 writes per minute per user.
Structured errors so the client can do something useful:
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
def on_validation(req, exc):
return JSONResponse(
status_code=422,
content={"data": None,
"error": {"code": "VALIDATION", "message": "Invalid input",
"fields": exc.errors()}},
)
Never send raw stack traces to clients. Log them server-side with a request ID, return the request ID to the client, and search logs by ID when a user complains.
Step 6 — Tests against a real database
Tests that mock the database are tests that lie. Use a test container:
# conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.db import engine, Base
@pytest.fixture(scope="session", autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def client():
return TestClient(app)
# tests/test_todos.py
def test_create_and_list_todos(client, auth_user):
r = client.post("/todos", json={"title": "buy bread"},
cookies={"sid": auth_user.sid})
assert r.status_code == 201
assert r.json()["data"]["title"] == "buy bread"
r = client.get("/todos", cookies={"sid": auth_user.sid})
assert r.status_code == 200
assert any(t["title"] == "buy bread" for t in r.json()["data"])
Coverage target: 80 percent line coverage for handlers and 100 percent of auth-related branches. pytest --cov=app shows you the gaps.
Step 7 — The build and deploy
Build a TODO API with:
POST /auth/signup,POST /auth/login,POST /auth/logoutGET /meGET /todos,POST /todos,GET /todos/{id},PATCH /todos/{id},DELETE /todos/{id}- Rate limiting on all auth endpoints
- 80 percent test coverage
- A Postgres database, migrated with Alembic (Python) or Drizzle Kit (Node)
Deploy to Fly.io or Railway. Both have a one-line deploy from a Dockerfile, free Postgres add-ons, and HTTPS by default. Module 03.5 goes deep on deploy.
Going deeper
- OWASP — Top 10 · the threats your endpoints will see in production.
- FastAPI advanced — Security · OAuth2 flows, scopes, when each is right.
- Hono docs · if you took the Node path, the canonical reference.
- Designing Data-Intensive Applications · Martin Kleppmann · the book to read once you have shipped two backends.
- Lucia auth docs · framework-agnostic, the clearest writing on session-based auth on the open web.
Skip the “Spring Boot vs FastAPI vs Express benchmarks” content. The performance difference between sane backends is dwarfed by your N-plus-1 queries.
Checkpoints
- For each of
POST /todos,GET /todos/{id},PATCH /todos/{id},DELETE /todos/{id}: name the success status code, the most likely 4xx, and what a 500 from each would mean. - Why argon2id and not bcrypt or sha256? What does a memory cost of 64MB actually buy you against an attacker?
- You’re building a single-frontend single-backend web app. Sessions or JWT? Defend your answer in two sentences.
- Name the four cookie attributes that make
Set-Cookiesafe for an auth session, and what each one prevents. - Walk through what happens when a request arrives with invalid JSON for
POST /todos: which layer rejects it, what status code is returned, and why your handler should never see the request.
When the API is deployed, the tests pass at 80 percent plus coverage, and you can curl the live URL to sign up and add a todo, move on to 03.4 PostgreSQL like a pro, where you’ll make this same database survive a load test.