Skip to content

Making the first admin without a back door

A fresh deploy has no admin, and the admin page needs one to add one. Use a terminal script with database or API credentials, never an env list of emails or a first-user-wins rule.

Admin panel3 min readships at docs/solutions/admin-panel/first-admin-without-a-backdoor.md

Tags: admin · roles · bootstrap · security · onboarding

Every admin panel has the same day-one problem. /admin is closed to anyone without the admin role. The "Make admin" button is inside /admin. The person who just deployed the app has no way in.

The shortcuts people reach for are all back doors.

Three shortcuts to avoid

ADMIN_EMAILS=me@company.com in the environment. Anyone who signs up with that address gets admin, and on most auth setups the address is unverified for a while after sign-up. If email verification is off, or an OAuth provider returns unverified emails, a stranger who types your address first is an admin. It also means a leaked env file names your admins, and removing an admin needs a redeploy.

The first user to sign up becomes admin. Fine on your laptop. On a public deploy it is a race you can lose to a bot that hits /sign-up before you do.

A hidden /setup route. A page that grants admin to whoever loads it is an admin grant for the internet, however unguessable the path looks.

A script with credentials

The safe bootstrap is a command that only someone holding the app's secrets can run:

bun run admin:grant you@example.com

It runs where the database URL or the auth provider's secret key already lives, finds the account by exact address, sets the role through the same code the panel uses, and writes an audit entry with no actor and via: "admin:grant". Sign up in the app first; the script refuses an address with no account, and says only that nothing changed, so it cannot be used to probe which addresses exist.

What the script writes depends on where roles live:

AuthRole lives inScript writes
Better Authuser.role columnthe column, directly (no admin session exists to call the API with)
ClerkpublicMetadata.roleusers.updateUserMetadata with the secret key
Supabase Authapp_metadata.rolea SECURITY DEFINER function only the service role may call

Match by exact address, ignoring case. A LIKE or ILIKE match is not exact: _ is a wildcard, so ana_b@x.io also matches anaxb@x.io, and the script would promote the wrong account.

When the role shows up

The user already signed in keeps the old role until their session catches up: up to the cookie cache on Better Auth (often 5 minutes), the next token refresh on Clerk (about a minute) or Supabase (up to an hour). Signing out and in again is instant. Print that line in the script's output so nobody debugs a working grant.

After the first one

Add every later admin from the panel, where the action is checked, audited and refused for the last admin's own demotion. Keep at least two admins, so one lost account does not lock everyone out, and treat the script as the break glass it is.

Checking your work

  • On a fresh database, /admin answers 404 or no-access for a signed-in user.
  • The script promotes an existing account and prints when the role applies.
  • It refuses an unknown address and a pattern like a_b@x.io that matches a different account.
  • The audit log shows the grant with no actor and via: admin:grant.