Skip to content
Metric VaultHelp Center
Open app

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#

#MechanismVerificationUsed by
ABody-supplied user_email, unverifiedNone. The handler reads body.user_email and trusts itThe overwhelming majority of customer endpoints: monitor, rank alerts, editorial, share, branding, schedules, social, Search Console, usage, notifications, library, premium-ai, tools
BVerified Supabase access tokenverifySupabaseUser(env, token) calls GET {SUPABASE_URL}/auth/v1/user/api/keys/create, /api/keys/list, /api/keys/revoke, /api/library/saved/delete
CAdmin Supabase token resolved to a D1 rolemvResolveAdmin(token, env) verifies the token then reads admin_usersEvery /api/admin/* route, plus /api/workflow/failures and /api/workflow/failure/resolve
DMV_INTERNAL_SECRET, operator or machinemvVerifyInternalSecret(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
ECustomer API keyAuthorization: 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#

js
// _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 null on any failure, so callers fail closed by construction.

C: admin resolution#

mvResolveAdmin(token, env) (_worker.js:18501-18528):

  1. Verify the token against /auth/v1/user and require a confirmed email. Any failure returns null.
  2. Read admin_users. A row is authoritative: status = 'active' grants its role; anything else denies, even if the email is in the seed list.
  3. 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 in ADMIN_EMAILS). A seeded address resolves to owner; anything else is denied.
  4. 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#

js
// _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.

SurfaceRule
/api/tools with a cost-0 typeAllowed 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 0401 {"error":"Please sign in to run this.","code":"auth_required"}
/api/translateNo 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/chatbotAnswers 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/failureNo 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.

GateFunctionDependency failsBehaviorWhy
Supabase token verificationverifySupabaseUserSupabase unreachable or non-OKClosed. Returns null, caller deniesAn unverifiable token must not become an identity
Admin resolution, token stepmvResolveAdminSupabase unreachableClosed. Returns nullSame
Admin resolution, role stepmvResolveAdminD1 unreachableOpen, to the seed floor only. A seeded address gets owner; everyone else is deniedA D1 outage must not lock the owner out of the console they need to fix it
Internal secretmvVerifyInternalSecretSecret unsetClosed. 503An unauthenticated caller must never be able to run cron or process an article job
Diagnostic gateinline at _worker.js:1405-1410Secret unsetClosed. 403Diagnostics hit billable upstreams and can leak configuration
Plan gaterequirePlanMONITOR_DB unbound or the plan lookup throwsClosed. 503 plan_check_failedA plan gate that opens during an outage lets every free caller into Enterprise features. The code says exactly that
Quota gate, config stepenforceAiQuota reading suspended:<email>D1 unreachableOpen. mvConfigAll returns an empty map, so nobody looks suspended"No config means every default applies, tools stay on"
Quota gate, binding stepenforceAiQuotaMONITOR_DB unboundOpen. 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 elseenforceAiQuotaAny exception after the binding resolvesClosed. 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 gateenforceAiRecoQuotaD1 errorClosed. 503 quota_check_failed or 503 plan_check_failedSame reasoning as the quota gate
Hourly rate limiterenforceLightRateLimitAny errorOpen. Returns nullIt is an abuse guard, not an entitlement check. Refusing real users to enforce a fair-use cap is the wrong trade
Seat checkteam invite pathAny errorClosed. 503 seat_check_failedSeats are a paid entitlement
Stripe webhookhandleStripeWebhookSTRIPE_WEBHOOK_SECRET unsetClosed. 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 cachecallDataForSEOCachedD1 unavailableOpen. Falls through to an uncached provider callFreshness degrades; correctness does not
Cache TTL configmvCacheTtlSecBad or missing config valueOpen to the caller's default"A bad config value can never break the core cache path or change data correctness, only freshness"
Translation/api/translateProvider errorOpen. Returns the source text"So the UI never blanks out"
Important

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:

  1. DEV_UNLIMITED_EMAILS resolves to unlimited.
  2. FORCE_FREE_EMAILS resolves to free.
  3. user_plans.plan, matched with LOWER(email).
  4. '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#

SymptomLikely causeWhere to look
401 {"error":"Unauthorized internal call"} from the cron endpointMV_INTERNAL_SECRET differs between the Pages project and the callerAll three copies of the secret
503 Internal jobs are not configured...The secret is unset on the Pages projectPages environment variables
All /api/diag/* return 403Same, or a wrong ?key=Same
403 {"error":"Unauthorized"} from every admin routeToken unverified, email unconfirmed, admin_users row suspended, or not in the seed flooradmin_users, then ADMIN_EMAILS
Admin console shows the login screen despite a valid staff account/api/admin/me returned a clean 403admin_users
Usage is not being meteredMONITOR_DB unbound, so the quota gate fails openThe deploy: wrangler.toml format and the absence of a positional directory
A suspended account can still run toolsThe config layer is failing open during a D1 problem, or the run costs 0 creditsD1 health
API key stops working with no changeThe owning account left the Enterprise planuser_plans
401 Invalid or revoked API keyThe key was revoked, or it does not existapi_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_email without 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 role field is display-only. Only blog_role has enforced capabilities.
  • There is no two-factor authentication, no SSO, no device management and no admin force-logout.

See also

Was this article helpful?