Architecture decision ADR-016

Lightweight admin auth via HTTP Basic for the editor inbox

Accepted · 2026-05-26

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:

What we DO need:

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:

Considered alternatives

  1. 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.
  2. 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.
  3. HMAC-signed cookie issued by a /admin/login form. 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.
  4. 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:

Negative:

Migration path to Clerk

When end-user accounts return (deferring ADR-016 in favor of Architecture reference §11):

  1. Add @clerk/nextjs to apps/web as planned.
  2. Replace the admin-path branch in apps/web/src/middleware.ts with auth().userId + role check (role === 'editor' || role === 'admin').
  3. Replace the FastAPI middleware's Basic-Auth check with a Clerk JWT verifier that reads the same Authorization header (now Bearer …).
  4. Remove ADMIN_USERNAME / ADMIN_PASSWORD from 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