An async FastAPI backend for a multiplayer party game: passwordless phone/OTP authentication, JWT sessions, a premium subscription tier, a question catalogue, and a self-hosted admin console.
Built and deployed to a Linux VPS behind PostgreSQL, serving a Unity mobile client. This repository contains the server and its admin panel.
Unity client ─┐
├─► FastAPI (JWT auth, premium gating) ─► PostgreSQL
Admin panel ──┘ │
└─► SMS gateway (OTP delivery)
- Passwordless auth. A phone number receives a one-time code by SMS; the first successful verification creates the account. No passwords are stored, so there is no password database to leak.
- Rate limiting on OTP issue, with a correct
Retry-Afterheader, so the endpoint cannot be used to run up an SMS bill. - Single-use codes. A verified code is destroyed immediately, so a replayed request cannot mint a second token.
- Role checks read the database, not the token. The JWT carries an
adminclaim, butget_current_adminre-reads theadminstable on every request — revoking an admin takes effect instantly instead of at token expiry. There is a test for exactly that. - Premium is a time window, not a boolean.
is_premium_activechecks the flag and the expiry date, so a lapsed subscription loses access without a cleanup job. - Swappable SMS backend. With no credentials configured the OTP is written to the log instead of sent, so the project can be cloned and run end to end without a paid SMS account.
- 19 tests covering the auth handshake, rate limiting, premium gating, cross-user access, and admin promotion/revocation.
sequenceDiagram
participant C as Client
participant A as API
participant S as SMS gateway
participant D as PostgreSQL
C->>A: POST /auth/send_otp {phone}
A->>A: rate-limit check (429 + Retry-After)
A->>S: send 4-digit code
A-->>C: {success: true}
C->>A: POST /auth/verify_otp {phone, otp}
A->>D: find or create user
A->>D: is this phone in admins?
A->>A: burn the code (single use)
A-->>C: {token: JWT, isPremium}
C->>A: any request + Bearer token
A->>D: resolve user, re-check role
A-->>C: response
Runs against SQLite with no SMS account, so a clone works immediately.
git clone https://github.com/SaeedSabzeh/fastapi-game-backend.git
cd fastapi-game-backend
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements-dev.txt
cp .env.example .envEdit .env — for a local run these two lines are enough:
DATABASE_URL=sqlite+aiosqlite:///./dev.sqlite3
JWT_SECRET_KEY=<paste the output of the command below>python -c "import secrets; print(secrets.token_urlsafe(48))"Create the tables and start the server:
python -m scripts.init_db
uvicorn app.main:app --reload- Interactive API docs: http://127.0.0.1:8000/docs
- Admin console: http://127.0.0.1:8000/panel/index.html
With SMS_API_KEY blank, POST /auth/send_otp prints the code to the console:
SMS disabled - OTP for 09121112233 is 3614
Use that code with /auth/verify_otp to get a token. To reach the admin
routes, insert your phone into the admins table after your first login.
Run the tests:
pytest| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/ |
— | Health check |
POST |
/auth/send_otp |
— | Send a login code (rate limited) |
POST |
/auth/verify_otp |
— | Verify the code, create account, return JWT |
POST |
/auth/validateToken |
Bearer | Session check on client start-up |
POST |
/auth/upgrade_premium |
Bearer | Grant premium, re-issue the token |
GET |
/api/questions |
— | Shared question pool, by game mode |
GET |
/api/my-questions |
Premium | The caller's own questions |
POST |
/api/my-questions |
Premium | Author a question |
DELETE |
/api/my-questions/{id} |
Premium | Delete one's own question |
GET / POST |
/user/customizations |
Bearer | Per-user client settings (JSON blob) |
GET POST PUT DELETE |
/admin/questions |
Admin | Manage the shared pool |
GET |
/admin/users |
Admin | List players and subscription state |
GET |
/admin/list-admins |
Admin | List admins |
POST |
/admin/add-admin |
Admin | Promote a registered player |
POST |
/admin/remove-admin |
Admin | Revoke admin rights |
app/
config.py Settings from env; refuses to boot without secrets
database.py SQLAlchemy Core tables + async connection
models.py Pydantic request/response schemas
crud.py Database access, no request objects
auth.py OTP flow, JWT creation, auth dependencies
sms.py SMS delivery, console backend for development
main.py App wiring: CORS, routers, lifespan, static panel
routers/
questions.py, customization.py, admin.py
scripts/init_db.py Table creation
static/index.html Admin console (vanilla JS, no build step)
tests/ 19 pytest cases
Every setting is an environment variable; see .env.example.
DATABASE_URL and JWT_SECRET_KEY are required and have no defaults — the
app raises a ValidationError at import time rather than starting with a
fallback secret that could reach production by accident.
Kept deliberately visible rather than papered over:
- OTP store is in-process. Codes live in a module-level dict, which is fine for a single worker but breaks under multiple uvicorn workers. Redis with a TTL is the drop-in replacement.
- 4-digit codes with a 60-second issue limit. The short code is a product
decision for mobile UX; it needs a per-phone attempt counter on
/auth/verify_otpbefore a wider launch. - No migration tool.
scripts/init_db.pycreates tables from the metadata; Alembic is the next step once the schema starts changing in production. upgrade_premiumhas no payment step. It grants 30 days directly, standing in for the payment-provider callback.- Rate limiting is per-process, for the same reason as the OTP store.
© 2026 Saeed Sabzeh. All rights reserved.
This source is published for portfolio and review purposes. No licence is granted to use, copy, modify, or distribute it.