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:
| Auth | Role lives in | Script writes |
|---|---|---|
| Better Auth | user.role column | the column, directly (no admin session exists to call the API with) |
| Clerk | publicMetadata.role | users.updateUserMetadata with the secret key |
| Supabase Auth | app_metadata.role | a 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,
/adminanswers 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.iothat matches a different account. - The audit log shows the grant with no actor and
via: admin:grant.