Skip to content
Metric VaultHelp Center
Open app

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.

RequirementDetail
A Supabase auth user exists for the addressProject cnjycxhmnepvqsuyrwem by default, overridable with SUPABASE_URL
The email is confirmedThe worker accepts the identity only when email_confirmed_at or confirmed_at is set on the Supabase user
The address is staffEither an admin_users row with status = 'active', or membership of the seed floor
MONITOR_DB is boundNeeded 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.

Capabilityadminowner
Load any screen and every read endpointYesYes
Resolve or reopen an error issue (/api/admin/errors/resolve)YesYes
Resolve a workflow failure (/api/workflow/failure/resolve)YesYes
Add, suspend, promote, demote or remove an adminNoYes
Set or delete a configuration keyNoYes
Change a cache lifetime, apply to all, clear the cacheNoYes
Run a background job nowNoYes
Set a plan, reset usage, refund, suspend, reactivate, remove a seatNoYes
Disconnect a customer's social channelNoYes
Moderate a customer blog postNoYes

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

Note: No admin endpoint is rate limited. The only 429 responses in the worker are customer quota gates.

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#

  1. Ask an existing owner to open the console and select Admins in the Operations group of the sidebar.
  2. In the Add an admin card, type the address into the field labelled name@example.com.
  3. Choose a role from the select: Admin for read-only staff, Owner for full mutation rights. The select defaults to Admin.
  4. Press Add admin. A blank field returns the client message Enter an email. A successful write shows Done. and the row appears in the Admin accounts table.
  5. 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_users row exists.
  6. The new admin opens /admin.html, enters the address and password, and presses Sign In.
Screenshot
The Admins screen with the owner-only "Add an admin" card open and the Admin accounts table below it.

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#

  1. Open /admin.html. The login card shows the METRIC VAULT wordmark and the subtitle Admin Dashboard.
  2. Fill in Email Address (placeholder Enter your email) and Password (placeholder Enter your password). Sign-in is email and password only; Google OAuth was removed because the console is staff-only.
  3. Press Sign In.
  4. The page calls supabase.auth.signInWithPassword, then immediately POSTs { token } to /api/admin/me. Only after that returns {ok:true} does the console render.
Screenshot
The admin login card showing the METRIC VAULT wordmark, the Admin Dashboard subtitle, the two fields and the Sign In button.

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.

  1. No token, no access. A missing token returns null immediately. The check fails closed at every stage.
  2. Verify the token with Supabase. The worker fetches <SUPABASE_URL>/auth/v1/user with Authorization: Bearer <token> and an apikey header. The apikey is taken from env.SUPABASE_ANON_KEY, then env.SUPABASE_SERVICE_KEY, then the anon key inlined in the worker.
  3. Require a confirmed email. The identity is accepted only when the Supabase user has email_confirmed_at or confirmed_at. The address is lower-cased. A network failure or a non-OK response returns null.
  4. Ask admin_users. The worker ensures the schema exists, seeds the table if it is empty, then runs SELECT role, status FROM admin_users WHERE email = ?. If a row exists the decision is made there and nowhere else: status = 'active' grants row.role || 'owner'; any other status denies, even for a seed address.
  5. Stamp last seen. When a role is granted, last_seen_at is updated to Date.now() in milliseconds. That is what the Admins table renders as a relative time.
  6. 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 addressResult
add with role adminRole is forced to owner. The downgrade never lands.
update that suspends or demotes400 {"error":"This is a protected owner account."}
remove400 {"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 addressResult
update that suspends or demotes400 {"error":"You cannot suspend or demote your own account."}
remove400 {"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#

SymptomLikely causeFix
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 floorHave an owner add the address on the Admins screen, or add it to ADMIN_EMAILS and redeploy
Invalid email or password.Supabase rejected the credentialsReset the password, or confirm the address exists in the Supabase project
An error occurred. Please try again.The sign-in call threw before a verdictRetry; if it persists, check that Supabase is reachable from the browser
Sign-in works but every screen is empty and the console logs 403sThe access token expired, or the Supabase user's email is not confirmedSign out and back in; confirm the email in Supabase
Screens load but no Save, Apply or Run buttons appearThe role is admin, not ownerAn 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 projectRestore the binding and redeploy; until then only seed-floor addresses can sign in
The whole console 403s for everyoneadmin_users was emptied or every row was suspendedA 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

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?