HTTP API that proves someone controls an email address for a given subject, via short-lived one-time codes.
UTMIST needs to confirm that a person actually controls an email before binding it to an identity — linking a UofT address to a directory person, gating a signup, confirming an on-behalf-of action. verification is the small, focused service that does exactly that and nothing else.
The flow is two calls:
- request-code — a consumer names a
subject(any opaque string it wants proven, e.g. a person id or a pending-signup token) and anemail. The service generates a 6-digit numeric code, emails it, and stores only an HMAC of the code. - confirm-code — the consumer submits the
subjectand the code the user typed back. On success it returns{verified: true, subject, email}— the caller now knows that email was reachable and belongs to whoever holds the subject.
The service is deliberately narrow: it keeps at most one live code per subject, never stores a code in the clear, and hardens the confirm path against replay and brute-force (see confirm-code semantics). It owns no identity data — binding the proven email to a person is the caller's job.
Prerequisites: Docker, Python 3.11+, uv.
# 0. From the repo root, enter the service directory (all commands below run here)
cd services/verification
# 1. Copy environment config and start Postgres
cp .env.example .env
docker compose up -d postgres
# 2. Install dependencies (including dev tools)
uv sync --extra dev
# 3. Apply database migrations
uv run alembic upgrade head
# 4. Start the API server
uv run uvicorn src.api.app:app --reload --port 8003The repo is a single uv workspace (root
pyproject.tomlwith[tool.uv.workspace] members = ["services/*", "packages/*"], one rootuv.lock). verification depends on the sharedplatform-authpackage ([tool.uv.sources] platform-auth = { workspace = true }) but the commands above are unchanged —uv sync,uv run pytest,uv run alembicstill work exactly as shown when run from this directory.
The API is now running at http://localhost:8003. Interactive Swagger UI is at http://localhost:8003/docs; the machine-readable schema is at http://localhost:8003/openapi.json.
The default dev API key is dev-api-key-change-me and the default HMAC secret is dev-hmac-secret-change-me (both set in .env). Pass the key as X-API-Key on every request:
# Request a code (the fake email backend just captures it in memory in dev)
curl -sS -X POST http://localhost:8003/verification/request-code \
-H "X-API-Key: dev-api-key-change-me" -H "Content-Type: application/json" \
-d '{"subject": "person:42", "email": "alex@example.com"}'
# Confirm it
curl -sS -X POST http://localhost:8003/verification/confirm-code \
-H "X-API-Key: dev-api-key-change-me" -H "Content-Type: application/json" \
-d '{"subject": "person:42", "code": "123456"}'Ports: this service runs on 8003 (team-tracking uses 8000, documentation-system 8001) and its Postgres container is exposed on 5434.
VF_ENV declares which environment this instance represents: local (default), staging, or production. In any non-local tier the app refuses to start (verify_production_secrets in src/config.py) if it still holds a built-in dev default or an unsafe setting — the dev API_KEY, the dev CODE_HMAC_SECRET, a dev_password@localhost DATABASE_URL, EMAIL_BACKEND=fake (which silently drops mail), or a selected real backend missing its required credentials. This is checked once at boot, so a misconfigured deploy fails fast instead of 202-ing every request while delivering nothing.
Auth is provided by the shared platform_auth package (build_auth in src/api/auth.py), configured env-key-only:
- Every request needs
X-API-Key. The only accepted key is the shared bootstrap env key (API_KEY), which carries theadminscope. There is no key-issuing CLI and no DB-backed keys here —NullApiKeyStore(src/storage/null_keys.py) short-circuits all key lookups, so thevf_<prefix>_<secret>envelope is reserved but unused. The env key must therefore be a plain opaque string, not shaped like that envelope. require_scope("verification:write")gates both verification endpoints. The scopes recognized areverification:writeandadmin(a wildcard that satisfies any check); the env key'sadminscope covers both./healthis unauthenticated.AuditLogMiddleware(fromplatform_auth, loggerverification.audit) logs one line per request with the resolved actor. The bootstrap key honors anX-Actorheader for the audit actor;dev:spoofis disabled.
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /verification/request-code |
verification:write |
Generate + email a one-time code for {subject, email}. 202 Accepted on success |
| POST | /verification/confirm-code |
verification:write |
Verify {subject, code}; 200 → {verified, subject, email} |
| GET | /health |
— (none) | Liveness probe → {"status": "ok"} |
Request/response shapes (contracts/types.py): both request bodies forbid extra fields. email is lightly validated (must contain @ and a dotted domain) and normalized to trimmed-lowercase; subject is trimmed and must be non-empty. request-code returns {"status": "sent"}; confirm-code returns {"verified": true, "subject": ..., "email": ...}.
Machine-readable OpenAPI schema: GET /openapi.json. Interactive Swagger UI: GET /docs.
- Rate limited per email. If an unconsumed code for that email was created within the last
RATE_LIMIT_WINDOW(60 seconds), the request is rejected with 429rate_limited. - Send-before-persist. The email is dispatched before the code row is written. If delivery fails (
EmailSendError), the endpoint returns 502email_send_failedand stores nothing — so the caller isn't rate-limited out of an immediate retry and no unsendable code is left behind. - Supersede-on-reissue. A fresh request for an existing subject replaces that subject's row (atomic upsert), so only the newest code is ever live.
- Codes are 6-digit numeric, generated with
secrets, expire afterCODE_TTL(10 minutes), and are stored only as an HMAC-SHA256 digest underCODE_HMAC_SECRET(src/codes.py). Verification is a constant-time compare; the plaintext code exists only in the email.
This path was hardened against replay and brute-force — the exact behavior:
- No pending code for the subject → 404
no_pending_code. - Single-use + TTL. The first correct code marks the row consumed (
mark_consumed) and returns{verified: true, subject, email}. - Attempt limit. Each wrong code atomically increments the subject's attempt counter; once it reaches
MAX_ATTEMPTS(5) further confirms return 429too_many_attempts. A wrong code returns 400invalid_code(or 429 if that attempt hit the cap). - Expiry. A code past its TTL that was never consumed → 410
expired. - Idempotent replay (correct code, still within TTL). Re-submitting the correct code against a consumed-but-unexpired subject re-returns
{verified: true, subject, email}. This makes retries safe. - Wrong code on a consumed-but-unexpired subject returns the normal failure — 400
invalid_code(no email is leaked) — and counts toward the same attempt limit. So a holder of theverification:writescope who never knew the code cannot replay an arbitrary subject to harvest its verified email, nor brute-force it against the consumed record. - Consumed + past TTL → 404
no_pending_code(the proof window has closed).
One SQLAlchemy Core table, verification_codes (src/storage/schema.py): subject (unique — enforces at most one live row per subject), email (indexed), code_hash, expires_at, attempts, consumed_at, created_at.
Two adapters implement the VerificationStore Protocol (contracts/storage.py):
PostgresVerificationStore(src/storage/postgres.py) — production.create_codeis anINSERT ... ON CONFLICT (subject) DO UPDATEupsert (safe under concurrent request-code calls).increment_attemptsis a singleUPDATE ... RETURNING attemptsstatement — an atomic read-and-increment, so parallel invalid confirms can't lose increments and slip past the attempt lockout.InMemoryVerificationStore(src/storage/in_memory.py) — used in tests and quick prototyping; same semantics, not persistent.
Schema is managed by Alembic (migrations/). Migration 001 creates verification_codes plus the email index. Apply locally with uv run alembic upgrade head. In production this runs automatically as the Railway preDeployCommand (railway.json) before each release — it is not run by the container CMD.
The sender is chosen by EMAIL_BACKEND and wired in src/api/deps.py behind the EmailSender Protocol (src/email/base.py); any provider failure is normalized to EmailSendError → a clean 502.
fake(default;src/email/fake.py) — captures messages in memory instead of sending. Used in tests and the dev playground; refused in non-localVF_ENV.resend(src/email/resend.py) — Resend HTTP API (HTTPS, works on any Railway plan). RequiresRESEND_API_KEYandEMAIL_FROM.gmail(src/email/gmail.py) — Gmail API via a service account with domain-wide delegation, impersonatingGMAIL_SENDER. RequiresGMAIL_SENDERandGMAIL_CREDENTIALS_JSON(base64-encoded service-account JSON).
verification/
├── contracts/ Domain boundary — no framework imports
│ ├── types.py Pydantic DTOs (RequestCode/ConfirmCode In/Out) + VerificationCode record
│ └── storage.py VerificationStore Protocol (create/get/latest/increment/consume)
│
├── src/
│ ├── api/ FastAPI application
│ │ ├── app.py create_app(): verify_production_secrets, audit middleware, /health, router
│ │ ├── auth.py build_auth() over platform_auth: require_scope, env-key-only (envelope "vf_")
│ │ ├── deps.py get_storage / get_email_sender / get_key_store wiring
│ │ └── routers/
│ │ └── verification.py request-code + confirm-code endpoints
│ │
│ ├── storage/ VerificationStore implementations
│ │ ├── schema.py verification_codes table (SQLAlchemy Core)
│ │ ├── in_memory.py InMemoryVerificationStore — tests
│ │ ├── postgres.py PostgresVerificationStore — production (atomic upsert + increment)
│ │ └── null_keys.py NullApiKeyStore — env-key-only auth (no DB keys)
│ │
│ ├── email/ EmailSender implementations + registry
│ │ ├── base.py EmailSender Protocol + EmailSendError
│ │ ├── fake.py FakeSender (in-memory capture; dev/tests)
│ │ ├── resend.py ResendSender (Resend HTTP API)
│ │ └── gmail.py GmailSender (Gmail API, service account)
│ │
│ ├── codes.py generate_code / hash_code / verify_code (HMAC-SHA256, constant-time)
│ ├── policy.py CODE_LENGTH=6, CODE_TTL=10m, RATE_LIMIT_WINDOW=60s, MAX_ATTEMPTS=5
│ └── config.py Settings (DATABASE_URL, API_KEY, CODE_HMAC_SECRET, VF_ENV, EMAIL_*) + prod-secret guard
│
├── migrations/ Alembic migrations (001_initial_schema)
├── tests/ Two-mode test suite (see Testing)
├── docker-compose.yml Local dev Postgres (host port 5434)
├── Dockerfile Production image (build context = repo root; uv sync --package verification)
└── railway.json Railway build/deploy: preDeployCommand = alembic upgrade head, healthcheck /health
Dependency direction: contracts/ imports nothing from src/. src/api/ depends only on contracts/ and src/config/src/codes/src/policy/src/email. The storage and email adapters each implement a contracts/base Protocol and know nothing about FastAPI; src/api/deps.py is the single wiring point.
The suite runs in two modes.
Fast (in-memory, no Docker required):
uv run pytest --ignore=tests/test_postgres_adapter.py -qRuns against InMemoryVerificationStore, injected into FastAPI via app.dependency_overrides (see tests/conftest.py), plus the FakeSender. Covers both endpoints, the full confirm-code state machine, rate limiting, the code HMAC helpers, all three email senders, config/prod-secret guards, and auth. Completes in well under a second.
Full (adds Postgres integration):
docker compose up -d postgres
uv run alembic upgrade head
uv run pytestAdds tests/test_postgres_adapter.py, which replays the adapter's behavioral assertions (including the atomic increment) against a live Postgres. They connect via DATABASE_URL (in .env) pointing at the running container on port 5434.
Lint and format with ruff:
uv run ruff check .
uv run ruff format --check .CI. The verification-test job in .github/workflows/ci.yml spins up a Postgres 16 service, runs uv sync --extra dev, uv run alembic upgrade head, the full uv run pytest, and both ruff check and ruff format --check. A separate step builds the Dockerfile and smoke-tests that import src.api.app resolves at boot.
One table (verification_codes), two endpoints plus /health, two storage adapters, three email backends, migration 001. The confirm-code path is hardened: single-use codes, 10-minute TTL, 5-attempt lockout, idempotent correct-code replay within the window, and no email leakage on wrong-code replay of a consumed subject. Env-key-only auth (verification:write / admin) with an audit log, and a boot-time guard that refuses to start a non-local deploy on dev secrets or the mail-dropping fake backend.
Not implemented (by design):
- DB-backed / per-consumer keys + CLI — auth is the shared env key only; the
vf_envelope is reserved but unused. - Identity binding — the service proves an email is reachable; associating it with a directory person is the caller's job (team-tracking owns identity).
- Resend/throttle beyond the 60s window and 5-attempt cap — no exponential backoff, per-IP limiting, or code-length/TTL configurability.
- Pagination / listing — there is no endpoint to enumerate codes; rows are looked up only by subject or email internally.
- docs/API.md — consumer-facing endpoint reference: request/response shapes, errors, curl examples
- docs/ARCHITECTURE.md — contributor orientation: why the service is shaped this way, boundaries, trade-offs
- docs/CONTRIBUTING.md — task walkthroughs and the pre-push checklist
- docs/DEPLOYMENT.md — deploy shape, variables, key provisioning, troubleshooting