Platform Admin Console
The MFA-gated /admin console: platform-owner authority, company and plan management, the job queue, and the audit trail.
/admin is a separate authority system for the SaaS owner/operator, not for tenants. It does not use the per-tenant RBAC the rest of the app uses (Role-Based Access Control) — nothing here is scoped by team.
Step-by-step: get your first platform-admin account
There is no signup form for platform admins. On purpose — see Why there's no signup form below. Instead: a real user signs up first, then gets promoted by someone with database access.
Do this once per environment — once for stage, once for production — not once per project.
1. Deploy with the two required secrets set. Every /admin/* route refuses to work without both — it fails closed, never degrades to an insecure mode:
sst secret set PLATFORM_MFA_SESSION_SECRET "$(openssl rand -base64 32)"
sst secret set PLATFORM_ADMIN_ENCRYPTION_KEY "$(openssl rand -base64 32)"
bun run deploy
2. The future owner signs up like any normal user — /signup or an OAuth provider, choosing their own password. Nobody else ever needs to know this password.
3. Promote that account to platform admin. Run this from apps/functions (not the repo root — that's the #1 mistake here), with the stage's real DATABASE_* values as environment variables:
cd apps/functions
$env:DATABASE_HOST="..."
$env:DATABASE_PORT="5432"
$env:DATABASE_NAME="..."
$env:DATABASE_USER="..."
$env:DATABASE_PASSWORD="..."
bun run src/database/grant-platform-admin.ts --email=owner@yourdomain.com --role=SUPER_ADMIN
This only works if that email already signed up in step 2 — the script errors with no user found otherwise. It never touches passwords, only grants a row in the PlatformAdmin table. It's a manual script by design, never run automatically on deploy: an automated grant is exactly how "one forgotten env var" turns into a permanent super-admin nobody remembers creating.
4. The owner logs in normally, then visits /admin. The app takes it from there: it detects MFA isn't set up yet and opens the enrollment dialog automatically — a centered, blurred-backdrop modal (the same AccountMfaSetupModal component used for a regular user's own 2FA setup, just pointed at the /api/admin/mfa/* endpoints instead). Click "Generate code", scan the QR with any authenticator app (Google Authenticator, 1Password, Authy...), and enter the 6-digit code it shows. That's it — the account now has full access to /admin.
Why there's no signup form
A self-serve "become the admin" form would have to sit at a public URL before anyone owns the platform — meaning whoever finds that URL first gets platform authority. There's no way to lock it down at that point, since locking it down is the whole point of having an admin in the first place. Promoting an existing, already-authenticated account sidesteps the problem entirely: authority is only ever handed to an account that's already provably owned by one person.
Local development and demos only: seed both accounts at once
seed/demo-accounts.ts creates both a platform owner and a demo tenant client in one call, with known default credentials — convenient for trying the product end to end, wrong for anything a stranger could reach:
AUTH_URL=... AUTH_ADMIN_SECRET=... \
bun run src/database/seed/demo-accounts.ts \
--owner-email=owner@yourdomain.com --client-email=client@yourdomain.com \
--owner-password=... --client-password=...
(AUTH_URL/AUTH_ADMIN_SECRET come from the shell environment, not the vault — see the script's own header comment for exactly where to read them from a deployed stage.) --owner-email and --client-email are required — there is no default address, the accounts are created on real addresses of yours. --owner-password/--client-password override the throwaway defaults defined in the script's parseArgs() — if you ever run this against an environment anyone outside your team can reach, always pass your own passwords, never the defaults. The defaults exist purely for local/demo convenience, precisely because they're meant to be throwaway; left unset on a reachable environment, a known owner password is a live credential-stuffing target, not a placeholder.
MFA after the first time: what to expect
Once enrolled, /admin stays open for 30 minutes at a time. After that, visiting any /admin page re-opens the same blurred modal — but it now just asks for a fresh 6-digit code instead of showing a QR again. Nothing to re-scan, no re-enrolling.
Wrong codes get rate-limited (10 attempts per 5 minutes, per account) — if you're locked out, wait a few minutes rather than retrying immediately.
Recovery codes: enrolling issues ten single-use recovery codes, shown once. If you lose your authenticator, choose Lost your authenticator? at the prompt and enter one in place of the six-digit code. Generate new codes on the console overview replaces the set after confirming an authenticator code. Known limit: if both are lost, an operator resets the factor with apps/functions/src/database/reset-mfa.ts --scope=platform (manual and audited), then you enroll again from scratch — visit /admin and follow the enrollment modal.
What the console does
- Companies (
/admin/companies) — cross-tenant list of every team, with search, plan and status filters; suspend and reactivate any team. Every action is scoped explicitly by team id, never by the caller's own memberships. - Plans (
/admin/plans) — create, edit, archive/unarchive plans (archiving is a soft delete:Team.planIdandSubscription.planIdare real foreign keys, so a plan row is never hard-deleted while teams reference it). Also runs the one-off Stripe cancel-configuration setup. - Queue (
/admin/queue) — job status counts, dead-letter messages with replay, and the last 20 cron log lines. Job processing is treated as infrastructure of the SaaS itself, not a tenant feature. - Audit log (
/admin/audit-log) — read-only trail of every platform-admin mutation:actorUserId · action · target · ip · createdAt. Writing is centralized in one function (recordAuditLog), never a direct insert from a router, so no future admin route can forget to log. The actor is looked up by a best-effort join, not a foreign key — a line survives the actor's own account being deleted later, which is exactly the line an operator most needs to see. - Notifications (
/admin/notifications) — a live demo of the real-time notification pipeline aimed at an arbitrary user: pick a team, pick a member, send them a notification, watch their bell update instantly. See Notifications for the procedure behind it.
Other known limits
- No tenant impersonation by the platform owner.
- No IP allowlisting for
/adminyet. - No self-service action to move an existing team onto a different plan — only creation and status changes.
Reference: how access is actually checked
Every /admin/* server procedure requires all three of these — you don't need to manage this yourself, it's just useful when debugging a FORBIDDEN error:
- An active (non-revoked) row for the user in the
PlatformAdmintable (from step 3 above). - MFA enrolled on that row.
- A currently valid elevated session (a code entered in the last 30 minutes).
Any of the three missing returns the same generic FORBIDDEN — the response never says which one failed. There are two roles, SUPER_ADMIN and SUPPORT; today both pass the same gate with no difference in what they can do.
Ready to build and launch your SaaS?
Get 100% full source code ownership, zero proprietary wrappers, and architecture engineered for millions of requests on Cloudflare.