Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.
Status: Accepted Date: 2026-05-26 Deciders: Daniel Meza Related: FEAT-015 — Lead Capture and CRM, Architecture reference §11.1 / §11.2 (target state — Clerk + RBAC), ADR-013 — Staging and Production with Manual Promotion
Context
The current release ships without user accounts (see Architecture reference §"Current Release Scope"). Clerk authentication, the platform's target-state identity provider, is deferred until end-user accounts come back. However, FEAT-015 introduces an internal admin lead inbox at /admin/leads/ that editors must reach to triage incoming leads, change status, anonymize on a deletion request, and export to CSV. That inbox holds the only PII the platform stores — Name + Email submitted via the lead-capture form.
We need authentication on the inbox before the bundle merges. We do NOT need:
- Per-editor identity (the editor team is single-digit in v1; audit-by-user is a phase-2 concern)
- Self-service editor onboarding (operator provisions credentials manually)
- SSO / OAuth / MFA (overkill for an internal tool used by ≤5 people)
- Anything Clerk-shaped (the whole point of the v1 simplification is to keep Clerk out)
What we DO need:
- Block anonymous traffic to
/admin/**(Next.js routes) and/api/v1/admin/**(FastAPI routes) before the bundle ships - Cleanly swap to Clerk later without rewriting the admin UI
- Zero changes to the per-route handlers — auth is a middleware concern
Decision
Gate every admin route with HTTP Basic Auth. Credentials come from two env vars (ADMIN_USERNAME, ADMIN_PASSWORD) and are compared in constant time. Both the Next.js middleware (page-level) and the FastAPI middleware (API-level) check the same env-var pair so the page and its data fetches are protected consistently.
Specifically:
- The Next.js middleware (
apps/web/src/middleware.ts) intercepts every request whose pathname starts with/admin. Missing or wrongAuthorization: Basic …header → 401 withWWW-Authenticate: Basic realm="urza-admin"(browser shows the native password prompt). - A FastAPI dependency (
apps/api/src/api/middleware/admin_auth.py) does the same for every route mounted under/api/v1/admin. Required as a defense-in-depth layer because the api is not directly exposed to the public internet, but operators occasionally hit it viacurlor port-forward. - Both env vars are required in
production; missing values cause the middleware to return 503 (refuse to serve any admin request rather than silently disable auth). - Staging may use a shared, well-known password; production uses a strong randomly-generated one stored in Railway's secrets.
Considered alternatives
- Clerk now (target-state). Rejected: brings in Clerk + per-editor user records + role mapping + RBAC infra weeks before end-user accounts are ready, defeating the v1 simplification.
- Shared bearer token in a custom header (
X-Admin-Token: …). Equivalent security to Basic, but loses the free browser-native password prompt. Editors would have to set the header in DevTools every session. Rejected for ergonomics. - HMAC-signed cookie issued by a
/admin/loginform. Better UX (signed cookie persists across tabs) but adds a login-form route, cookie signing, expiry logic — significantly more code for marginally better UX with a single-digit editor team. - IP allowlist via Railway's network controls. Insufficient: editors triage on mobile/coffee-shop networks, and we cannot statically enumerate IPs. Acceptable as a defense-in-depth supplement but not as the primary gate.
Consequences
Positive:
- Less than 100 lines of middleware code, no new external dependencies.
- Editors get the browser's native credential-saving (Keychain / 1Password / Bitwarden) for free.
- Drop-in swap to Clerk later: the middleware is the only file that changes. Route handlers continue to use whatever
request.state.rolefield the new middleware sets. - Same env-var pair gates both the page layer and the api layer — operators can't accidentally protect one and forget the other.
Negative:
- No per-editor audit trail in v1. The application audit log records which row was modified, but not which editor did it. Acceptable for a ≤5-person team; revisit if the editor team grows beyond ~10 or if we need per-user revocation.
- Anyone with
ADMIN_PASSWORDhas full admin access. Rotation = redeploy with a new env var (acceptable cadence for v1). - Basic Auth is sniffable over HTTP. Mitigated by Railway terminating TLS at the edge: every request to a public Railway domain arrives as HTTPS at the proxy, regardless of what the client sent, and the backend services are only reachable through that proxy. We deliberately do NOT add an in-app
req.url.protocol === 'http:'reject because the inner service sees an HTTP hop from the Railway proxy → app — a check on the local-leg protocol would either be a no-op (correct value masked by the proxy) or would fightX-Forwarded-Protoparsing. If we ever move off Railway, add a TLS check at the new edge before flippingADMIN_PASSWORDover.
Migration path to Clerk
When end-user accounts return (deferring ADR-016 in favor of Architecture reference §11):
- Add
@clerk/nextjstoapps/webas planned. - Replace the admin-path branch in
apps/web/src/middleware.tswithauth().userId+ role check (role === 'editor' || role === 'admin'). - Replace the FastAPI middleware's Basic-Auth check with a Clerk JWT verifier that reads the same
Authorizationheader (nowBearer …). - Remove
ADMIN_USERNAME/ADMIN_PASSWORDfrom Railway envs.
The route handlers and the admin UI do NOT change. The request.state.role field stays the same shape; only its provenance changes.
Env vars
| Name | Purpose | Required in |
|---|---|---|
ADMIN_USERNAME |
The single shared admin login. Convention: urza-editor. |
staging + production |
ADMIN_PASSWORD |
The matching password. Strong random in production; throwaway-but-not-default in staging. | staging + production |
When either env var is missing in production, every admin request returns 503 Service Unavailable with a clear error message. This is intentional — silently allowing anonymous access if the env var is forgotten would be worse than a hard outage.
Acceptance
- Visiting
/admin/leads/without credentials returns 401 +WWW-Authenticate: Basic realm="urza-admin". curl -X GET /api/v1/admin/leadswithout credentials returns 401.- Both layers accept the same
ADMIN_USERNAME:ADMIN_PASSWORDpair. - Constant-time comparison is documented and used (no early-return on first character mismatch).
- Missing env var in production → 503; missing env var in development → 401 with developer-friendly hint.