Multi-user TODO API with JWT auth
This is the most common junior backend interview project on the planet: REST API, auth, persistence, tests. Builds the muscle hiring managers screen for first.
Resume bullet (when finished)
“Built a multi-user REST API with JWT auth, rate-limiting, and PostgreSQL backed by 65% test coverage and a Docker deploy.”
Locked tech stack
No "choose your language" — analysis paralysis kills completion. Follow the stack to the letter on your first build.
Milestones (7 · ~15h)
- M1~1h
Scaffold + /health endpoint
FastAPI server runs on :8000; GET /health returns 200 with {status: 'ok'}.
CHECK BEFORE MOVING ON:
- What does uvicorn do that FastAPI itself doesn't?
- Why is /health a separate endpoint from /?
$ git commit -m "feat: scaffold project + /health endpoint" - M2~2h
User registration + bcrypt hashing
POST /register accepts {email, password}, persists user with password hashed via bcrypt. Returns 201 on success, 409 on duplicate email.
CHECK BEFORE MOVING ON:
- Why bcrypt over SHA-256 for passwords?
- What's the cost factor and why would you bump it in 2026?
$ git commit -m "feat(auth): user registration with bcrypt password hashing" - M3~2h
JWT login + protected route
POST /login returns a JWT bearer token. GET /me requires it; returns the user. Wrong token returns 401.
CHECK BEFORE MOVING ON:
- What should NEVER be inside a JWT payload?
- What's the difference between access tokens and refresh tokens?
$ git commit -m "feat(auth): JWT login + protected /me endpoint" - M4~3h
TODO CRUD per user
GET/POST/PATCH/DELETE /todos — each user only sees their own. Pagination with ?limit + ?offset.
CHECK BEFORE MOVING ON:
- How do you enforce 'user only sees their own todos' — at the SQL layer or in Python?
- What's wrong with PATCH that accepts arbitrary JSON?
$ git commit -m "feat(todos): CRUD scoped to the authenticated user" - M5~2h
Rate limiting on /register and /login
Limit /register to 5/min/IP and /login to 10/min/IP. Use slowapi or roll your own with Redis.
CHECK BEFORE MOVING ON:
- Why do auth endpoints need stricter limits than CRUD?
- What's a good 429 response body shape?
$ git commit -m "feat: rate-limit auth endpoints" - M6~3h
Tests — 65% coverage minimum
pytest + httpx async client. Cover auth happy + sad paths, CRUD per-user isolation, rate-limit trigger.
CHECK BEFORE MOVING ON:
- What's the difference between a unit and an integration test in a FastAPI project?
- How do you test rate-limiting without sleeping in the test?
$ git commit -m "test: 65% coverage across auth + todos" - M7~2h
Docker + README
Multi-stage Dockerfile. README covers: setup, first request, schema diagram, design decisions.
CHECK BEFORE MOVING ON:
- Why multi-stage builds vs one big image?
- What's the smallest practical base image for production Python in 2026?
$ git commit -m "ops: multi-stage Dockerfile + production README"
60-second demo storyboard
What you say in the recruiter screen when they ask "tell me about your latest project." Practice it out loud.
- 0-5s: 'I built a multi-user TODO API with auth, rate-limiting, and Docker.'
- 5-15s: Live demo — POST /register, POST /login, see the JWT come back, paste it as bearer token.
- 15-30s: GET /todos — only your own. Try with a stolen token from another user → 403.
- 30-45s: Show the rate-limiter — six rapid /login attempts → 429 with Retry-After.
- 45-55s: One design decision in plain English (e.g. 'I chose RLS at the SQL layer instead of Python filtering because…').
- 55-60s: 'Code on GitHub, deployed at <url>. Would love your feedback on the auth design.'
STAR talking points for behavioral round
STAR — DEBUGGING CHALLENGE
Situation: tests passed locally but failed in CI. Task: find why. Action: discovered bcrypt's cost factor was different across environments via an env-var quirk. Result: added a guard in conftest.py pinning cost to 4 in tests, full test suite back to green, also documented the env-var.
STAR — ARCHITECTURE TRADE-OFF
Situation: had to choose between Python filtering and Postgres RLS for the 'user sees only their todos' guarantee. Task: justify the choice. Action: chose RLS — single source of truth, can't accidentally bypass with a missed WHERE. Result: zero authorization bugs in the test suite, even when I deliberately tried to leak data with a mis-scoped query.
STAR — SHIPPED UNDER DEADLINE
Situation: needed to ship the project before applying to roles. Task: scope down without losing portfolio signal. Action: cut websockets, kept REST + auth + rate-limiting + tests + Docker. Result: shipped in 12 hours instead of 25, still hit all four resume bullets that matter for junior backend.
Production references — how grown-up systems do this
Stripe →
Stripe's REST API uses a similar bearer-token + idempotency-key pattern; their docs are the gold standard.
Supabase →
Supabase Row Level Security is the easiest production-grade way to enforce 'user only sees their rows' — same idea, Postgres-native.
GitHub →
GitHub's REST API uses scope-based token permissions — the next step up from a single JWT once your app has more than one role.
Self-review rubric (before you claim done)
Correctness
- All endpoints match the OpenAPI spec (auto-generated via FastAPI).
- Auth: wrong token returns 401, missing token returns 401, expired token returns 401.
- CRUD: user can only see + edit their own todos. Cross-user attempts return 403 or 404 (not 200).
- Rate limiter: 6th rapid /login from same IP returns 429 with Retry-After.
Code quality
- No N+1 queries (verify with SQL log on /todos list).
- Type hints on every function signature, including async ones.
- Pydantic models for every request + response — no naked dicts.
- Secrets via env vars only — never committed.
Testing
- ≥65% line coverage (pytest --cov).
- At least one integration test that hits the real DB.
- Auth happy + sad paths both covered.
- Rate-limiter test does not sleep — uses dependency override.
Docs
- README has: setup, first API call, schema diagram, three design decisions written in plain English.
- .env.example checked in, .env in .gitignore.
- OpenAPI URL linked in README so reviewers can poke around at /docs.
✱ AI code review
Get a senior-style review before you call it done
Push your finished work to GitHub, open a PR, paste the PR URL below. Claude reviews the diff against this project's rubric and replies with strengths, must-fix items, and one teachable principle.
Tick the rubric items honestly, write the README, push to GitHub, get the AI review above. Once it's clean, email support@learnpython.academy with the repo link — we feature the best ones on /success-stories.
Need Python first? Start Foundations →