Getting admin access
How an admin signs in, how the worker verifies the token, and how the admin_users table, the ADMIN_EMAILS seed floor and the protection guards decide who gets in.
Last updated 2026-08-06
Summary#
Admin access is decided in two places: Supabase proves who you are, and the D1 table admin_users decides whether that identity is staff and what role it has. A short hardcoded seed list plus the ADMIN_EMAILS environment variable act as a fail-safe floor so a listed owner is never locked out when D1 is unreachable. This page walks the whole chain, names every guard, and gives the exact steps to grant a new admin.
Purpose#
Two properties had to hold at once. Staff needed to be added and removed without a code deploy, which means the list must live in the database. And a database outage must not lock the owner out of the console they would use to diagnose it, which means there must be a floor that does not depend on the database. The design satisfies both: admin_users is authoritative when it answers, and the seed list answers when it cannot.
Requirements#
Before an address can sign in as an admin, all of the following must be true.
| Requirement | Detail |
|---|---|
| A Supabase auth user exists for the address | Project cnjycxhmnepvqsuyrwem by default, overridable with SUPABASE_URL |
| The email is confirmed | The worker accepts the identity only when email_confirmed_at or confirmed_at is set on the Supabase user |
| The address is staff | Either an admin_users row with status = 'active', or membership of the seed floor |
MONITOR_DB is bound | Needed for admin_users and for every screen. Unbound throws MONITOR_DB not bound. Wrangler may need a redeploy. |
The address is always compared lower-cased.
Permissions#
There are two roles. Both see every screen. The difference is mutation rights.
| Capability | admin | owner |
|---|---|---|
| Load any screen and every read endpoint | Yes | Yes |
Resolve or reopen an error issue (/api/admin/errors/resolve) | Yes | Yes |
Resolve a workflow failure (/api/workflow/failure/resolve) | Yes | Yes |
| Add, suspend, promote, demote or remove an admin | No | Yes |
| Set or delete a configuration key | No | Yes |
| Change a cache lifetime, apply to all, clear the cache | No | Yes |
| Run a background job now | No | Yes |
| Set a plan, reset usage, refund, suspend, reactivate, remove a seat | No | Yes |
| Disconnect a customer's social channel | No | Yes |
| Moderate a customer blog post | No | Yes |
Owner-only endpoints reply 403 with a specific message, for example {"error":"Only an owner can manage admins."} or {"error":"Only an owner can change config."}. The UI mirrors the same split by keeping the owner-only cards (#adminsAddCard, #configEditCard, #maintenanceCard, #recoCard, #tcOwnerCard) hidden until /api/admin/me reports role: "owner". The hiding is convenience, not the control. The server check is the control.
Note: No admin endpoint is rate limited. The only 429 responses in the worker are customer quota gates.
Navigation Path#
https://metricvaultai.com/admin.html
The page is a plain static asset. The worker has no route for /admin or /admin.html, so there is no server-side redirect and no gate on the HTML itself. Loading the page is not access. Every /api/admin/* call it makes is gated independently.
Step-by-Step Guide#
Grant access to a new admin#
- Ask an existing owner to open the console and select Admins in the Operations group of the sidebar.
- In the Add an admin card, type the address into the field labelled
name@example.com. - Choose a role from the select:
Adminfor read-only staff,Ownerfor full mutation rights. The select defaults toAdmin. - Press Add admin. A blank field returns the client message
Enter an email.A successful write showsDone.and the row appears in the Admin accounts table. - Have the new admin create a Supabase account for that exact address and confirm the email. Without the confirmation the worker will not accept the identity, even though the
admin_usersrow exists. - The new admin opens
/admin.html, enters the address and password, and presses Sign In.
Grant access without the console#
If nobody can sign in, add the address to the ADMIN_EMAILS environment variable on the Cloudflare Pages project as a comma-separated value and redeploy. That puts the address on the seed floor, which grants owner whenever admin_users has no row for it or D1 cannot be read. It is the recovery path, not the normal path.
Sign in#
- Open
/admin.html. The login card shows theMETRIC VAULTwordmark and the subtitleAdmin Dashboard. - Fill in
Email Address(placeholderEnter your email) andPassword(placeholderEnter your password). Sign-in is email and password only; Google OAuth was removed because the console is staff-only. - Press Sign In.
- The page calls
supabase.auth.signInWithPassword, then immediately POSTs{ token }to/api/admin/me. Only after that returns{ok:true}does the console render.
How the chain actually resolves#
mvResolveAdmin(token, env) in _worker.js is the single decision point. Every admin endpoint calls it, directly or through the mvVerifyAdmin wrapper, so no route can be left ungated by accident.
- No token, no access. A missing token returns
nullimmediately. The check fails closed at every stage. - Verify the token with Supabase. The worker fetches
<SUPABASE_URL>/auth/v1/userwithAuthorization: Bearer <token>and anapikeyheader. The apikey is taken fromenv.SUPABASE_ANON_KEY, thenenv.SUPABASE_SERVICE_KEY, then the anon key inlined in the worker. - Require a confirmed email. The identity is accepted only when the Supabase user has
email_confirmed_atorconfirmed_at. The address is lower-cased. A network failure or a non-OK response returnsnull. - Ask
admin_users. The worker ensures the schema exists, seeds the table if it is empty, then runsSELECT role, status FROM admin_users WHERE email = ?. If a row exists the decision is made there and nowhere else:status = 'active'grantsrow.role || 'owner'; any other status denies, even for a seed address. - Stamp last seen. When a role is granted,
last_seen_atis updated toDate.now()in milliseconds. That is what the Admins table renders as a relative time. - Fall back to the seed floor. If there is no row, or D1 threw, the decision is undecided and the seed floor answers: on the list means
owner, off the list means denied.
The seed floor#
mvAdminSeedList(env) returns two hardcoded addresses, albertdbrown85@gmail.com and metricvaulttestacc@gmail.com, concatenated with every entry in env.ADMIN_EMAILS split on commas, trimmed and lower-cased.
The table is seeded from that list exactly once, when admin_users is empty. Each seed address is inserted as role='owner', status='active', added_by='seed'. After that the table is authoritative and the seed list only serves as the outage floor and as the input to the protection guards below.
The seed-owner guard#
Seed addresses cannot be locked out through the console.
| Attempt on a seed address | Result |
|---|---|
add with role admin | Role is forced to owner. The downgrade never lands. |
update that suspends or demotes | 400 {"error":"This is a protected owner account."} |
remove | 400 {"error":"This is a protected owner account."} |
The Admins list returns locked: true on those rows and the UI renders the literal word protected in place of the action buttons.
The self-action guard#
An owner cannot remove their own footing either.
| Attempt on your own address | Result |
|---|---|
update that suspends or demotes | 400 {"error":"You cannot suspend or demote your own account."} |
remove | 400 {"error":"You cannot remove your own account."} |
Promoting yourself is not blocked, because you must already be an owner to reach the endpoint at all.
Token transport and session handling#
The console reads session.access_token and sends it as a token field in the JSON body of every admin call. There is no Authorization header on these routes. supabase.auth.onAuthStateChange re-verifies on SIGNED_IN and signs out any identity that fails, and page load restores the session with getSession() before running the same server check.
Two client behaviors are worth knowing when you debug a lockout. A 403 from /api/admin/me is treated as authoritative: the console shows You are not authorized to access this dashboard. and signs the session out. Any other failure, such as a 5xx or a network error, falls back to a one-entry client list containing albertdbrown85@gmail.com so a transient outage cannot lock the owner out of the page shell. That fallback only affects what the page renders. It grants nothing, because the endpoints still verify independently.
Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
You are not authorized to access this dashboard. after a correct password | /api/admin/me returned 403: no active admin_users row and not on the seed floor | Have an owner add the address on the Admins screen, or add it to ADMIN_EMAILS and redeploy |
Invalid email or password. | Supabase rejected the credentials | Reset the password, or confirm the address exists in the Supabase project |
An error occurred. Please try again. | The sign-in call threw before a verdict | Retry; if it persists, check that Supabase is reachable from the browser |
| Sign-in works but every screen is empty and the console logs 403s | The access token expired, or the Supabase user's email is not confirmed | Sign out and back in; confirm the email in Supabase |
| Screens load but no Save, Apply or Run buttons appear | The role is admin, not owner | An owner promotes the account with Make owner on the Admins screen |
MONITOR_DB not bound. Wrangler may need a redeploy. | The D1 binding is missing on the Pages project | Restore the binding and redeploy; until then only seed-floor addresses can sign in |
| The whole console 403s for everyone | admin_users was emptied or every row was suspended | A seed-floor address still resolves to owner when there is no row, so sign in as one and repair the table |
Errors on the login card auto-hide after 5000 ms.
Warning: The Forgot password? link always sends the reset email to the hardcoded address albertdbrown85@gmail.com, whatever is typed in the email field, and the success message names it: Password reset email sent to <ADMIN_EMAIL>. Check your inbox. Reset other admins' passwords from the Supabase dashboard instead.
FAQs#
Do I need to redeploy after adding an admin? No. admin_users is read on every request, so a row added on the Admins screen takes effect on the new admin's next sign-in. Only ADMIN_EMAILS requires a redeploy, because it is an environment variable.
What is the difference between suspending an admin and removing one? Suspending sets status = 'suspended' and keeps the row. The row is authoritative, so the address is denied even if it is on the seed floor. Removing deletes the row, which drops the decision back to the seed floor: a seed address would then resolve to owner again. That is why removal of a seed address is blocked outright.
Can an admin escalate themselves to owner? No. /api/admin/users/mutate returns 403 {"error":"Only an owner can manage admins."} before it reads the action.
Does signing an admin out revoke their access immediately? Not entirely. Signing out revokes refresh tokens, but an already-issued Supabase access token stays valid until it expires. There is no force-logout endpoint on this Supabase version. See What the admin console cannot do yet.
Is the admin page itself protected by anything at the edge? Nothing in the repository configures Cloudflare Access or an edge rule for /admin.html, and _headers has no admin entry. Treat the per-endpoint token check as the only access control that is guaranteed to be in place.
Is admin activity logged? Every mutation is, with actor, action, target and a short meta string. Reads are not. See Audit log.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.