# 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.

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:

```sh
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, `/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`.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
