Why a separate auth flow
The admin surface is the most dangerous part of the API: a single compromised admin can ban users, delete listings, and read private tenancy documents. So the admin auth flow is deliberately not the user auth flow with a flag flipped. The reasons (BACKEND_PLAN §5.15 / §14.1, QUESTIONS.md §8.1):
- MFA is mandatory for admins, irrelevant for users. Injecting a TOTP step into the shared
session.service would warp the 99% non-admin path. A separate admin-auth.service keeps each flow simple.
- Token claims differ. Admin access tokens carry
scope: 'admin'; user tokens carry scope: 'user'. The requireAdmin middleware rejects anything that isn’t scope === 'admin' — see Cross-realm protection.
- Blast-radius isolation. Admin refresh tokens live in their own
admin_refresh_tokens collection. A breach of the user refresh surface cannot escalate into admin access.
The two-step login
Admin login is two requests. Step 1 verifies the password and hands back a short-lived ticket; step 2 exchanges the ticket + a TOTP code for the real tokens. No access or refresh token is ever issued from step 1.
The ticket is a 5-minute JWT with scope admin-mfa-pending — it authorises exactly one thing (the mfa-verify exchange) and nothing else. If it expires before step 2, the client re-runs step 1.
The missing-admin and wrong-password paths return the same ADMIN_INVALID_CREDENTIALS code, and the server runs a throw-away Argon2 verify on the missing-admin path so response timing is comparable. This makes the login endpoint enumeration-safe — an attacker cannot probe which emails are admins.
Token lifetimes
| Token | Type | TTL | Scope / collection |
|---|
mfaTicket | signed JWT | 5 minutes | scope: 'admin-mfa-pending'; not persisted. |
accessToken | RS256 JWT | 15 minutes | scope: 'admin'; not persisted (stateless). |
refreshToken | opaque, hashed | 7 days | row in admin_refresh_tokens. |
Refresh is single-use rotation: each call to /admin/auth/refresh revokes the presented token and issues a new pair (see Admin refresh).
TOTP enrollment
MFA is enrolled at bootstrap time, not via a self-service flow. The seed-admin CLI generates a base32 TOTP secret and prints it once to stdout:
SEED_ADMIN_EMAIL=admin@swappr.co.uk \
SEED_ADMIN_PASSWORD=initial-password-12chars \
pnpm seed:admin
The operator scans the printed secret into an authenticator app (Google Authenticator, 1Password, etc.) immediately. Properties:
- Base32 secret, standard 30-second TOTP window, 6-digit codes (
/^\d{6}$/).
- Printed once. There is no “show secret again” endpoint. If the secret is lost, the only recovery is a super-admin manually deleting the admin row from MongoDB and re-running
seed:admin.
- The CLI refuses to run if any admin already exists — this prevents accidental privilege escalation via an env-var-driven reseed in CI/CD. The created row is always
role: SUPER, mfaEnabled: true.
Recovery codes are Phase 7. For now, a lost authenticator means lost access: a super-admin must delete the affected admin row in the database and re-seed. There is no in-band reset.
Blast-radius isolation
Admin refresh tokens live in a separate collection (admin_refresh_tokens), never mixed with the user refresh_tokens collection. This is a containment boundary:
- A bug or breach that exposes user refresh tokens cannot be replayed against the admin surface.
- Admin sessions can be mass-revoked without touching user sessions, and vice-versa.
Because the stored token hash is salted (Argon2), refresh lookup is a bounded candidate-scan over recent rows rather than a single indexed lookup. This is acceptable: the admin population is tiny (tens of admins, low hundreds of tokens), so the scan is cheap. Mirrors the user-side findByTokenHashCandidate pattern.
Cross-realm protection
The requireAdmin middleware enforces the realm boundary on every admin route:
// require-admin.ts
if (claims.scope !== 'admin') {
next(new AppError('UNAUTHENTICATED', { detail: 'Admin scope required' }));
return;
}
A perfectly valid user access token presented at an admin route is rejected with 401 UNAUTHENTICATED — not 403 — so the admin surface never even confirms “we received a real token, just the wrong kind.” Identity is established here; role authorization (SUPER vs MODERATOR vs FINANCE) is a separate per-route concern (see below).
Role taxonomy
Three admin roles. Identity (requireAdmin) is shared; action authorization is enforced per route by a requireAdminRole([...]) gate that runs after requireAdmin.
| Role | Capabilities |
|---|
| SUPER | Everything, including the destructive retention manual-trigger. |
| MODERATOR | User moderation (ban/unban), tenancy decisions (approve/reject), listing deletion. Cannot trigger retention. |
| FINANCE | Read-only billing surface — Phase 7. On the user/tenancy/listing surfaces FINANCE may list (read) but every mutating action returns 403 FORBIDDEN. |
The read endpoints (user search, tenancy queue, listings list, reports feed) are available to all roles. The mutating endpoints carry an explicit role gate, documented per page.
Bootstrap
The MVP ships one SUPER admin, created via the seed CLI (QUESTIONS.md §8.1):
SEED_ADMIN_EMAIL=admin@swappr.co.uk \
SEED_ADMIN_PASSWORD=initial-password-12chars \
pnpm seed:admin
Password must be ≥ 12 chars. Additional admins (MODERATOR, FINANCE) and password rotation are Phase 7 (/admin/auth/change-password).
Deferred to Phase 7
- Recovery codes for lost MFA (today: DB-level super-admin reset only).
- Self-service password change (
/admin/auth/change-password).
- Creating additional admins beyond the seeded SUPER, and the FINANCE billing read surface.
- Report moderation actions (dismiss / warn / ban-from-report) — see Admin reports feed.
See also