Authentication and authorization flow
The five identity mechanisms in the worker, how each is verified, and an exact account of which gates fail open, which fail closed, and why each choice was made.
Last updated 2026-08-06
Summary#
There is no server-side session and no cookie. Identity is a Supabase JWT held in browser localStorage, and the worker verifies it only where it must. Most customer endpoints still trust a user_email supplied in the request body. Five distinct credential types exist, each with its own verification path and its own failure behavior.
The single most important thing to know is that the gates are not uniformly fail-closed. The plan gate fails closed. The quota gate fails closed for database errors but fails open when MONITOR_DB is entirely unbound. The hourly rate limiter fails open. The configuration layer, which is where account suspension lives, fails open. Each of those choices is deliberate and each is documented in the code. This page lays them out side by side so you do not have to reconstruct them from memory.
Purpose#
Two audiences need this page. An engineer adding an endpoint needs to know which gate to call and what it returns. An engineer investigating an incident needs to know what happens to authorization when a dependency is down, because "the database was unreachable" produces a different outcome at each gate.
The asymmetry exists because the risks are not symmetrical. Letting an unauthenticated caller into a paid feature costs money and trust; refusing a legitimate caller because a configuration table was briefly unreadable costs availability for no security benefit. Each gate was tuned to the cost of getting it wrong in that direction.
Architecture#
The five mechanisms#
| # | Mechanism | Verification | Used by |
|---|---|---|---|
| A | Body-supplied user_email, unverified | None. The handler reads body.user_email and trusts it | The overwhelming majority of customer endpoints: monitor, rank alerts, editorial, share, branding, schedules, social, Search Console, usage, notifications, library, premium-ai, tools |
| B | Verified Supabase access token | verifySupabaseUser(env, token) calls GET {SUPABASE_URL}/auth/v1/user | /api/keys/create, /api/keys/list, /api/keys/revoke, /api/library/saved/delete |
| C | Admin Supabase token resolved to a D1 role | mvResolveAdmin(token, env) verifies the token then reads admin_users | Every /api/admin/* route, plus /api/workflow/failures and /api/workflow/failure/resolve |
| D | MV_INTERNAL_SECRET, operator or machine | mvVerifyInternalSecret(request, env) reads the x-mv-internal-secret header, or ?key= on a GET | /api/cron/run, /api/article-process, /api/billing/sync, and by a direct ?key= compare every /api/diag/*, /api/dbgdfs and the Higgsfield operator sign-in |
| E | Customer API key | Authorization: Bearer mv_live_<48 hex> looked up in api_keys, must not be revoked | /api/v1/analyze only |
A: body-trusted identity, and the hardening that is not live yet#
user_email appears 460 times in _worker.js. Most handlers do const { user_email } = await request.json() and use it directly. The browser builds that body with window.mvBody(extra), which merges the signed-in email and the active workspace selection.
dashboard.html already patches window.fetch so every same-origin /api/ request carries Authorization: Bearer <access token>, and it never overwrites an existing Authorization header. That is C1 Phase A, and it is purely additive: the token is sent, and almost nothing reads it.
resolveIdentity(request, env, bodyEmail) (_worker.js:18312-18323) is the other half of Phase A. It prefers a verified bearer token, skips mv_live_ keys because those have their own path, and falls back to the body email, returning { email, verified }. It now has call sites - the workspace resolver and the key-management routes among them.
That description is superseded. Both modes landed on main: MV_C1_ENFORCE appears throughout _worker.js, and mvC1Identity - which prefers a verified bearer token and falls back to the claimed address - is called from roughly forty routes. The flag is not set in production, so those routes run in warn mode: they log the unverified call and continue, and the ones that refuse an anonymous caller today do it with their own if (!_c1.email) check rather than through enforcement.
Billing no longer waits for the flag. Warn mode returns the claimed address with verified: false, so a route could resolve identity, look correct, and still charge the address the request named. Every route that spends credits now requires _c1.verified whatever MV_C1_ENFORCE says, and answers an unverified caller 401 unauthorized — or 503 auth_unavailable when the verifier itself could not be reached, because a provider outage is not a signed-out customer. Carrying that requirement: the premium-AI core and so the whole premium tool surface, Cited Sources, both rank-alert routes, both image routes, Social Publish, Originality, Fact Check, Growth Actions, Specialist and the light-tool path. The newer ones call mvC1Require(_c1, message), which returns the refusal and picks the 503 itself; the earlier ones spell the same check inline.
Two shapes satisfy it, and which one a route uses depends on whether anonymous use is part of the product. Most refuse outright. /api/analyst and the extension quickview instead pick the payer off the flag — _c1.verified ? _c1.email : '' — so an unverified caller falls through to the anonymous path rather than to a stranger's balance. Analyst has to: it answers the public marketing chat as well as the in-app assistant, and demanding a session would refuse every prospect. Billing a forged claim to nobody is the same guarantee arrived at from the other side.
tests/billing-identity-sweep.mjs derives the set from source and fails if a charging route satisfies neither shape. Worth knowing what it missed before, as the failure mode recurs: its header promised this check for a fortnight while traces() only ever looked for the word "verified" on mvBillingEmail's workspace-redirect argument, so eight routes reported green while charging the claim. It now also rejects a payer that ORs in a request-supplied address, and a guard whose refusal is computed and then discarded.
Cost-0 routes are deliberately outside that rule. /api/chatbot and the rank tracker's chat answer anonymous callers on purpose, capped per IP by mvAnonIpCap, and no credits move.
Ownership is a separate question and still rests on a claimed address until the flag is switched on, which is the residual recorded in Authorization model.
One destructive endpoint already ignores the body email on purpose. POST /api/library/saved/delete derives identity from the verified session token so a caller can only ever delete their own rows, even before enforcement lands.
Workspace scoping is verified server-side regardless. resolveWorkspaceEmail(db, requesting_email, active_workspace) returns the requester unless there is a team_memberships row where member_email = requester AND owner_email = requested AND accepted_at IS NOT NULL. A forged active_workspace therefore gains nothing; a forged user_email currently does.
B: verifySupabaseUser#
// _worker.js:18287-18304
const SUPABASE_URL = env.SUPABASE_URL || '<hardcoded project URL>';
const apikey = env.SUPABASE_ANON_KEY || env.SUPABASE_SERVICE_KEY || <hardcoded anon JWT>;
const r = await fetch(SUPABASE_URL + '/auth/v1/user', {
headers: { 'Authorization': 'Bearer ' + token, 'apikey': apikey }
});
if (!r.ok) return null;
const u = await r.json();
if (!u || !u.email) return null;
if (!(u.email_confirmed_at || u.confirmed_at)) return null;
return String(u.email).toLowerCase();Three properties:
- It is a network round trip to Supabase on every call. There is no local JWT signature verification and no caching.
- It independently enforces email confirmation, regardless of the Supabase project's own toggle. An unconfirmed address is never an identity.
- It returns
nullon any failure, so callers fail closed by construction.
C: admin resolution#
mvResolveAdmin(token, env) (_worker.js:18501-18528):
- Verify the token against
/auth/v1/userand require a confirmed email. Any failure returnsnull. - Read
admin_users. A row is authoritative:status = 'active'grants its role; anything else denies, even if the email is in the seed list. - With no row, or if D1 is unreachable, fall back to the seed floor (
albertdbrown85@gmail.com,metricvaulttestacc@gmail.com, plus any comma-separated addresses inADMIN_EMAILS). A seeded address resolves toowner; anything else is denied. - Stamp
last_seen_at.
There are two admin roles, owner and admin. Both can read every /api/admin/* data endpoint. Only owner can mutate customers, manage admins, change configuration, run jobs or clear caches. The refusal strings are literal, for example Only an owner can manage customers. and Only an owner can run jobs. Every admin route answers an unauthorised caller with HTTP 403 and {"error":"Unauthorized"}, not 401.
The seed floor exists so a D1 outage cannot lock the primary owner out. The mirror image is in the client: admin.html treats a clean 403 from /api/admin/me as authoritative "not an admin", but a 5xx, 404 or network error falls back to a local list containing the primary owner only.
D: the internal secret#
// _worker.js:23889-23903
if (!env.MV_INTERNAL_SECRET) → 503 {"error":"Internal jobs are not configured (MV_INTERNAL_SECRET is not set)."}
got = header 'x-mv-internal-secret' || ?key=
if (got !== secret) → 401 {"error":"Unauthorized internal call"}The diagnostic gate at the top of the router is a separate inline check with a different shape: ?key= only, and 403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store.
Both fail closed when the secret is unset. The rationale is written next to the article-writer handoff: with no guessable fallback, an unset secret makes the receiver return 503 and the job stays queued rather than being processed by an unauthenticated call. The value must be identical in three places: the Cloudflare Pages environment, the cron-worker Worker secret, and the GitHub repository secrets.
E: customer API keys#
generateApiKey() produces 'mv_live_' plus 48 hex characters from 24 CSPRNG bytes. The key is stored in plaintext as the primary key of the api_keys table and compared directly on lookup. Creation requires the enterprise plan and there is a hard cap of 5 active keys per account (Key limit reached (5 active keys). Revoke one first.). Revocation is a flag; rows are never deleted. There are no scopes. /api/v1/analyze re-checks the owning account's current plan on every call, so a downgrade disables existing keys immediately.
Anonymous and unauthenticated callers#
Not every route requires an identity, and the rules differ per route.
| Surface | Rule |
|---|---|
/api/tools with a cost-0 type | Allowed with no user_email. This is what keeps the ten free technical tools and the public /free-tools/* pages working. Anonymous callers are rate-limited by a bucket keyed 'ip:' + CF-Connecting-IP instead of by email |
/api/tools or /api/premium-ai with a cost above 0 | 401 {"error":"Please sign in to run this.","code":"auth_required"} |
/api/translate | No identity at all. Every caller, signed in or not, is rate-limited on the IP-keyed hourly bucket, because the endpoint bills Workers AI or DeepL |
/api/chatbot | Answers signed-out visitors on purpose, capped per IP by mvAnonIpCap. A caller who presents a token is resolved through mvC1Identity and metered on the hourly fair-use bucket. It forwards up to the last ten user/assistant turns (each truncated to 4000 characters) to gpt-4o with a system prompt built by mvChatbotSystemPrompt() from MV_PUBLIC_PLANS and the plan tables |
/api/workflow/failure | No auth. The client reports its own failed step. Reading those rows back is admin-only, because they carry customer emails and error text |
GET /share/<slug> | Public by design. An expired report returns 410, a missing one 404 |
GET /api/blog/media/file/... | Public by design, so customer sites can embed the images |
GET /team/accept/<token> | Public. Accepting an invite needs only the token plus the invited email string, with no proof that the person owns that address |
Everything else expects at least a user_email in the body, which today is mechanism A and therefore unverified.
Components#
Fail open or fail closed: the complete matrix#
This is the section to read during an incident.
| Gate | Function | Dependency fails | Behavior | Why |
|---|---|---|---|---|
| Supabase token verification | verifySupabaseUser | Supabase unreachable or non-OK | Closed. Returns null, caller denies | An unverifiable token must not become an identity |
| Admin resolution, token step | mvResolveAdmin | Supabase unreachable | Closed. Returns null | Same |
| Admin resolution, role step | mvResolveAdmin | D1 unreachable | Open, to the seed floor only. A seeded address gets owner; everyone else is denied | A D1 outage must not lock the owner out of the console they need to fix it |
| Internal secret | mvVerifyInternalSecret | Secret unset | Closed. 503 | An unauthenticated caller must never be able to run cron or process an article job |
| Diagnostic gate | inline at _worker.js:1405-1410 | Secret unset | Closed. 403 | Diagnostics hit billable upstreams and can leak configuration |
| Plan gate | requirePlan | MONITOR_DB unbound or the plan lookup throws | Closed. 503 plan_check_failed | A plan gate that opens during an outage lets every free caller into Enterprise features. The code says exactly that |
| Quota gate, config step | enforceAiQuota reading suspended:<email> | D1 unreachable | Open. mvConfigAll returns an empty map, so nobody looks suspended | "No config means every default applies, tools stay on" |
| Quota gate, binding step | enforceAiQuota | MONITOR_DB unbound | Open. try { db = monRequireDB(env); } catch (e) { return null; } | An unbound binding is a deploy fault, not a usage signal. This is the one gap worth knowing about |
| Quota gate, everything else | enforceAiQuota | Any exception after the binding resolves | Closed. 503 quota_check_failed | "Block the request so unlimited AI/tool calls cannot leak through when usage tracking is broken. Previously this returned null which let everything through" |
| Recommendation gate | enforceAiRecoQuota | D1 error | Closed. 503 quota_check_failed or 503 plan_check_failed | Same reasoning as the quota gate |
| Hourly rate limiter | enforceLightRateLimit | Any error | Open. Returns null | It is an abuse guard, not an entitlement check. Refusing real users to enforce a fair-use cap is the wrong trade |
| Seat check | team invite path | Any error | Closed. 503 seat_check_failed | Seats are a paid entitlement |
| Stripe webhook | handleStripeWebhook | STRIPE_WEBHOOK_SECRET unset | Closed. 503 webhook_not_configured | "An unverified webhook can set anyone to any plan, so an unconfigured secret must never mean 'trust it'." The reconcile job keeps entitlements correct until the secret is added |
| DataForSEO cache | callDataForSEOCached | D1 unavailable | Open. Falls through to an uncached provider call | Freshness degrades; correctness does not |
| Cache TTL config | mvCacheTtlSec | Bad or missing config value | Open to the caller's default | "A bad config value can never break the core cache path or change data correctness, only freshness" |
| Translation | /api/translate | Provider error | Open. Returns the source text | "So the UI never blanks out" |
Important: the two behaviors inside enforceAiQuota are different. A completely unbound MONITOR_DB allows the request through unmetered. Every other database failure blocks it with 503. If you are diagnosing unmetered usage, check the binding first.
Plan resolution#
resolveUserPlan(db, user_email, fallbackPlan) is the single source of plan truth, in this precedence:
DEV_UNLIMITED_EMAILSresolves tounlimited.FORCE_FREE_EMAILSresolves tofree.user_plans.plan, matched withLOWER(email).'free'.
The fallbackPlan argument is deliberately ignored. It used to be usage_counters.plan, which defaults to 'starter' and silently upgraded every Free user.
PLAN_RANK = { free: -1, starter: 0, pro: 1, agency: 2, enterprise: 3, unlimited: 99 }. An unmapped plan ranks -1 so it can never pass a gate.
Account suspension#
Suspension is a platform_config row keyed suspended:<lowercased email> with value '1', set and cleared only by an owner through POST /api/admin/customer/mutate. It is enforced in both quota gates and returns 403 {"error":"This account is suspended. Please contact support.","code":"account_suspended"}.
Two limits are worth stating plainly:
- It does not invalidate the Supabase session. A suspended user can still sign in and load the dashboard. Metered API calls fail.
- It is read through the fail-open config layer. During a D1 outage a suspended account is momentarily not suspended.
There is no admin force-logout anywhere in the product. The only session-revocation surface is the user's own Log Out All, which calls signOut({ scope: 'global' }).
The browser side#
dashboard.html is a public static document. The worker applies no auth check to /dashboard or /dashboard.html; it only maps the clean URL to the asset. Protection is entirely client-side plus per-endpoint. The #authOverlay element covers the page at z-index: 9999 until getSession() resolves, then either lifts or shows Sign in to continue. When an OAuth return is still in flight the prompt is deferred by 8000 ms instead of 1500 ms so a returning user never sees a flash of the signed-out state.
Data flow#
Three representative requests.
A customer runs a tool. The browser posts {type, query, user_email} with an Authorization: Bearer header attached by the patched fetch. The handler reads user_email from the body and ignores the header. enforceAiQuota resolves the plan from user_plans, checks suspension through platform_config, applies the Free-plan block or the quota comparison, and either returns a Response or null. Metering follows immediately.
An admin loads the console. admin.html posts the Supabase access token to /api/admin/me. mvResolveAdmin verifies it against Supabase, requires a confirmed email, reads admin_users, and answers {ok, email, role} or 403. Every subsequent screen sends the same token in its body; there is no session.
The scheduler fires. metricvault-cron POSTs /api/cron/run with x-mv-internal-secret. mvVerifyInternalSecret reads that header, falls back to ?key= for a GET ping, and compares the value against env.MV_INTERNAL_SECRET with a plain !==. It is a direct string comparison, not a constant-time one. A match returns null, which allows the request. The jobs then run inside ctx.waitUntil, so the HTTP response returns immediately with {ok:true, triggered:[...], at:<ISO>}.
Failure modes#
| Symptom | Likely cause | Where to look |
|---|---|---|
401 {"error":"Unauthorized internal call"} from the cron endpoint | MV_INTERNAL_SECRET differs between the Pages project and the caller | All three copies of the secret |
503 Internal jobs are not configured... | The secret is unset on the Pages project | Pages environment variables |
All /api/diag/* return 403 | Same, or a wrong ?key= | Same |
403 {"error":"Unauthorized"} from every admin route | Token unverified, email unconfirmed, admin_users row suspended, or not in the seed floor | admin_users, then ADMIN_EMAILS |
| Admin console shows the login screen despite a valid staff account | /api/admin/me returned a clean 403 | admin_users |
| Usage is not being metered | MONITOR_DB unbound, so the quota gate fails open | The deploy: wrangler.toml format and the absence of a positional directory |
| A suspended account can still run tools | The config layer is failing open during a D1 problem, or the run costs 0 credits | D1 health |
| API key stops working with no change | The owning account left the Enterprise plan | user_plans |
401 Invalid or revoked API key | The key was revoked, or it does not exist | api_keys |
Known gaps, stated plainly#
These are true on main today and should not be documented as anything else:
- Most customer endpoints trust
body.user_emailwithout verification. The hardening exists on a branch, not in production. - API keys are stored in plaintext in D1.
- Google OAuth tokens are base64-obfuscated, not encrypted, and social tokens are stored raw. The code says so explicitly.
- Team invite tokens never expire, and accepting an invite requires only the token plus the invited email string with no proof of ownership.
- The workspace
rolefield is display-only. Onlyblog_rolehas enforced capabilities. - There is no two-factor authentication, no SSO, no device management and no admin force-logout.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.