Authorization model
Every authorization gate in the platform, the exact resources each one protects, the denial it returns, and how each behaves when its dependency is unavailable.
Last updated 2026-08-06
Summary#
The platform has five credential types and two quota gates that also act as authorization. They are independent: a route uses one, several, or none. This page maps each gate to the resources it actually protects, states the exact denial it returns, and records what happens to authorization when the dependency behind it is unavailable.
Authentication and authorization flow is the request-level walkthrough of the same machinery. This page is the surface map and the risk register: what is protected, what is not, and what the blast radius of each failure is.
Purpose#
Three jobs need this page. Adding an endpoint means choosing the right gate and knowing what it returns. Reviewing the platform means knowing where the trust boundaries actually sit rather than where they are assumed to sit. Handling an incident means knowing whether a dependency outage opened a gate or closed it, because the answer is not the same at every gate.
The gates are deliberately not uniformly fail-closed. Entitlement gates fail closed because letting an unauthenticated caller into a paid feature costs money and trust. Abuse guards and configuration lookups fail open because refusing legitimate users to enforce a fair-use ceiling is the wrong trade. Each choice is documented next to the code that makes it.
Architecture#
The trust boundaries#
| Boundary | What crosses it | What is trusted on the far side |
|---|---|---|
| Browser → worker | Authorization: Bearer <session token>, plus a JSON body carrying user_email and active_workspace | The token where a handler verifies it. The body email everywhere else |
| Worker → Supabase | The session token, for verification | Supabase's answer, including email_confirmed_at |
| Worker → D1 | Parameter-bound SQL | The admin_users, user_plans and platform_config rows as authoritative |
| Scheduler → worker | x-mv-internal-secret | Nothing else. The header is the whole credential |
| Stripe → worker | A signed webhook body | Only after HMAC verification with a 300-second replay window |
| Customer script → worker | Authorization: Bearer mv_live_… | The key row, plus a live re-check of the owner's plan |
Public visitor → /share/<slug> | Nothing | Nothing. The page is intentionally unauthenticated |
Two boundaries are weaker than they look and must be stated plainly.
The /dashboard document is not edge-gated. The worker maps the clean URL to the static asset and serves it to anyone; the sign-in overlay is client-side. The same is true of /admin. This leaks interface structure, not data, because every API behind both screens is gated server-side.
Most customer API handlers derive identity from the request body. The canonical sniffing helper mvActUserEmail(url, headers, body) takes the first address it finds in body.user_email, body.userId, body.email, the x-mv-user header, then ?user_email= or ?email=. That is the C1 class described under Failure modes.
mvC1Identity is live on main, and being in it is not the same as being closed. Every route that resolves a caller now calls mvC1Identity(request, env, claimed), which verifies the Supabase bearer token when one is present and returns:
| Field | Meaning |
|---|---|
email | the verified address, or the unverified claim when there is no proven session |
verified | whether email was proven rather than claimed |
providerDown | Supabase could not be reached, as opposed to the token being bad |
denied | a ready-made refusal, populated only when MV_C1_ENFORCE=1 |
MV_C1_ENFORCE is unset in production. So in warn mode denied is always null and email is whatever the caller typed, and a route whose only check is if (_c1.denied) return _c1.denied is trusting that value. The flag being off is the point of warn mode: it lets the programme be rolled out without moving behaviour for anyone. It also means the warn-mode fallback is itself the vulnerability on any route where the address chooses whose rows are read or whose allowance is spent.
mvC1Require(_c1, message) is the guard for those routes. It requires a proven session regardless of the flag, and it answers 503, not 401, when providerDown is true because a 401 tells the browser its session is dead and signs the user out, so a Supabase outage would otherwise become a mass logout that outlives the outage. Routes using it refuse a forged identity today, with the flag off.
Live on main with that guard:
- every
/api/social/*route:connections,disconnect,publish,schedule,scheduled,scheduled/cancel(mediareaches the same result throughmvProfileIdentity) /api/usage/api/crawl/schedule, both GET and POST/api/responsive/get,/delete,/deviceand/chat, but only for a row that HAS an owner. A row created by the signed-out free tool has none, and stays reachable by its id because that id is the only handle its creator ever had. A verified caller who is not the owner still gets a 404 rather than a 401, so one customer cannot use the difference to confirm another row exists.- every route that spends credits:
/api/premium-ai(the whole premium tool surface, throughmvPremiumAICore),/api/cited-sources,/api/rank-alerts/create,/api/rank-alerts/check-now,/api/image/analyzeand/api/image/create, alongside Originality, Fact Check, Growth Actions, Specialist and the light-tool path, which carry the same requirement inline. Warn mode returns the claim withverified: false, so a route could resolve identity, look correct, and still charge the address the request named.
/api/analyst is the one charging route that does NOT demand a session, because it also answers the public marketing chat. It picks the payer off the flag instead — _c1.verified ? _c1.email : '' — so a forged or anonymous caller is billed to nobody while the chat still works. Either shape satisfies the rule; which one a route uses depends on whether anonymous use is part of the product.
Cost-free routes are deliberately outside it. /api/chatbot and the rank tracker's chat answer anonymous callers on purpose, capped per IP by mvAnonIpCap, and no credits move.
tests/billing-identity-sweep.mjs derives every charging call site from source and fails if one bills an address its own function never proved. Its header claimed that check for a fortnight before it made it: traces() demanded the word "verified" only on mvBillingEmail's workspace-redirect argument, so eight routes of exactly the shape described here reported green while charging the claim. Mutation-tested now, including the case where the guard is called and its refusal discarded.
The API reference already documented all of these as Verified session. The code was the half that did not agree.
The legitimate callers are unaffected because the dashboard's fetch wrapper attaches the Supabase bearer to every string /api/ URL once the session has loaded. Genuinely public routes, the /api/tools free tools among them, are deliberately NOT behind this guard and are bounded by per-IP caps instead.
tests/c1-sweep.mjs probes every derived route twice, once with MV_C1_ENFORCE=1 and once with the flag unset, and fails if a must-be-gated route answers a forged identity in either mode. The second pass is the one that describes production; before it existed the sweep reported /api/social/publish as gated while it was accepting posts to a stranger's connected accounts.
The five credential types#
| # | Credential | Verified by | Protects |
|---|---|---|---|
| 1 | Supabase session token | verifySupabaseUser(env, token) | API key create / list / revoke, and saved-work deletion |
| 2 | Plan tier, from the caller's email | requirePlan(env, email, minTier) | Schedule creation, API key creation, the public analyze endpoint, white-label branding |
| 3 | Admin session token plus a D1 role | mvResolveAdmin(token, env) / mvVerifyAdmin | Every /api/admin/* route, plus the workflow-failure endpoints |
| 4 | MV_INTERNAL_SECRET | mvVerifyInternalSecret(request, env) and inline ?key= checks | Cron execution, the article-writer handoff, billing sync, every diagnostic endpoint, the Higgsfield operator sign-in |
| 5 | Customer API key mv_live_… | Inline lookup in handlePublicApiAnalyze | POST /api/v1/analyze and nothing else |
Components#
Gate 1 — the verified session token#
verifySupabaseUser calls GET {SUPABASE_URL}/auth/v1/user with the token and an apikey header, then independently requires email_confirmed_at || confirmed_at. An unconfirmed address is never an identity, regardless of the Supabase project's own toggle. It returns null on any failure, so callers fail closed by construction. There is no local JWT signature check and no cache on main: every verification is a live round trip.
Four routes use it: POST /api/keys/create, POST /api/keys/list, POST /api/keys/revoke and POST /api/library/saved/delete.
handleSavedDelete is the model the rest of the platform is meant to follow. It reads Authorization: Bearer, verifies it, answers 401 {"ok":false,"deleted":0,"error":"unauthorized"} if that fails, caps the batch at 200 integer ids, and scopes the DELETE with AND user_email = ?, so a caller can only ever delete their own rows.
Gate 2 — the plan gate#
requirePlan resolves the plan through resolveUserPlan and compares it against PLAN_RANK = { free: -1, starter: 0, pro: 1, agency: 2, enterprise: 3, unlimited: 99 }. An unmapped plan ranks -1 and can never pass.
| Denial | Status | Body |
|---|---|---|
| No email supplied | 400 | {"error":"user_email required"} |
| Plan lookup unavailable | 503 | {"error":"Plan check temporarily unavailable, please retry.","code":"plan_check_failed"} |
| Below the required tier | 403 | {"error":"This feature requires the <Tier> plan. Your account is on <Plan>. Upgrade to unlock it.","code":"upgrade_required", …} |
There are exactly four call sites: schedule creation (starter), API key creation (enterprise), the public analyze endpoint (enterprise, re-checked on every call so a downgraded key stops working), and branding save (pro). Everything else that is plan-sensitive is gated through the quota path instead.
Warning: The doc comment above requirePlan still reads "Fails OPEN on a DB error". The body fails closed. Trust the code, not the comment.
Gate 3 — admin#
The authoritative store is the D1 table admin_users(email, role, status, …). A row wins: status = 'active' grants its role, anything else denies even for a seeded address. With no row, or if D1 throws, resolution falls back to the seed floor — two hardcoded addresses plus any comma-separated entries in ADMIN_EMAILS — which resolve to owner. That fallback is deliberate so a D1 outage cannot lock the owner out of the console needed to fix it, and it is reachable only after a valid, confirmed Supabase token has already been proven.
Two roles exist. Both read every admin data endpoint. Only owner mutates.
| Owner-only action | Refusal |
|---|---|
POST /api/admin/users/mutate | Only an owner can manage admins. |
POST /api/admin/customer/mutate | Only an owner can manage customers. |
POST /api/admin/config/set | Only an owner can change config. |
POST /api/admin/jobs/run | Only an owner can run jobs. |
POST /api/admin/cache/clear | Only an owner can clear the cache. |
POST /api/admin/cache/setall | Only an owner can change cache settings. |
POST /api/admin/social/disconnect | Only an owner can disconnect a channel. |
Every admin route answers an unauthorised caller with HTTP 403 and {"error":"Unauthorized"}, not 401. Guardrails on admin mutation refuse self-suspension (You cannot suspend or demote your own account.), self-removal (You cannot remove your own account.) and any action against a seeded owner (This is a protected owner account.).
Mutations are written to admin_audit_log(ts, actor, action, target, meta). The writer is best-effort by design and swallows its errors, so an audit write can fail silently while the privileged action still succeeds. Admin reads are not audited at all, and /api/admin/data is on the activity-logger quiet list. See Audit log.
Gate 4 — the internal secret#
mvVerifyInternalSecret returns 503 {"error":"Internal jobs are not configured (MV_INTERNAL_SECRET is not set)."} when the secret is unset, rather than falling back to a derivable value. It accepts the secret from the x-mv-internal-secret header, or from ?key= on a GET, and answers a mismatch with 401 {"error":"Unauthorized internal call"}. The comparison is a plain string compare, not constant-time; a timing-safe helper exists but is used only for Stripe signatures.
The diagnostic block at the top of the router is a separate inline check with a different shape: ?key= only, answering 403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store. It covers /api/dbgdfs and every /api/diag/* route, because each probe hits a real billable upstream and can echo configuration state.
The secret must be identical in three places: the Cloudflare Pages environment, the cron-worker Worker secret, and the GitHub repository secrets. See Internal endpoints and Environment variables and secrets.
Note: The cron Worker's own fetch() handler is unauthenticated. Anyone who knows its URL can trigger a job run. The jobs self-throttle in the database, so the effect is extra invocations rather than extra work, and the Pages endpoint still requires the secret.
Gate 5 — customer API keys#
mv_live_ plus 48 hex characters from 24 CSPRNG bytes: 192 bits of entropy. Creation requires a verified session and the Enterprise plan, with a hard cap of five active keys (Key limit reached (5 active keys). Revoke one first.). Revocation is a flag scoped to the owner. There are no scopes and no expiry. Storage and the operational handling that follows are covered in API key security.
The quota gates, which are also authorization#
enforceAiQuota and enforceAiRecoQuota do more than count. They are where anonymous access, the Free-plan boundary and account suspension are enforced.
| Condition | Status | Code |
|---|---|---|
| No identity, cost above zero | 401 | auth_required |
| No identity, cost zero | allowed | The free technical tools stay public |
suspended:<email> is '1' | 403 | account_suspended |
| Free plan, cost above zero | 403 | upgrade_required |
| Monthly allowance exhausted | 429 | quota_exceeded |
| Recommendations below the minimum tier | 403 | reco_upgrade_required |
| Recommendation allowance exhausted | 429 | reco_quota_exceeded |
| Hourly light-tool ceiling of 100 reached | 429 | hourly_rate_limit |
Suspension is a platform_config row keyed suspended:<lowercased email>, set and cleared only by an owner. It blocks metered work; it does not invalidate an existing session, and there is no force-logout anywhere in the product.
Workspace scoping#
resolveWorkspaceEmail(db, requesting_email, active_workspace) returns the requester unless a team_memberships row exists where member_email = requester AND owner_email = requested AND accepted_at IS NOT NULL. A forged active_workspace therefore gains nothing. Note that the workspace role column is display-only: an accepted member has the same access regardless of the badge shown. Only blog_role carries enforced capabilities. See Roles and what each can do.
Outbound request authorization#
mvAssertPublicUrl is the guard on every URL the platform will fetch on a caller's behalf. It rejects non-http(s) schemes and blocks private, loopback, link-local, CGNAT, multicast and cloud-metadata addresses in dotted, decimal, octal, hex and IPv6 notations, including IPv4-mapped forms. It is applied to /api/tools targets, competitor-monitor URLs and webhook destinations (Webhook host is not allowed). It does not resolve DNS, so a public name that resolves to a private address is not caught, and redirects are not re-validated after the fact. Both limits are acknowledged in the code.
Data flow#
A customer runs a tool. The browser posts {type, query, user_email} with a bearer token attached by the patched fetch. The handler reads user_email from the body. enforceAiQuota resolves the plan, checks suspension, applies the Free-plan block or the quota comparison, and returns either a Response or null. Metering follows at the call site.
An admin loads the console. admin.html posts the session token to /api/admin/me. mvResolveAdmin verifies it, requires a confirmed email, reads admin_users, stamps last_seen_at, and answers {ok, email, role} or 403. Every subsequent screen resends the token in its body. There is no admin session.
The scheduler fires. metricvault-cron POSTs /api/cron/run with x-mv-internal-secret. Jobs run inside ctx.waitUntil and the HTTP response returns immediately.
A script calls the API. The bearer key is looked up, checked for revocation, the owner's current plan is re-verified against Enterprise, and six credits are metered before the analysis runs.
Failure modes#
Dependency failure: open or closed#
| Gate | Dependency fails | Result | Security consequence |
|---|---|---|---|
| Session verification | Supabase unreachable | Closed, indistinguishable from a bad token | Key management and saved-work deletion stop working during a provider outage |
| Admin token step | Supabase unreachable | Closed | The console is unusable |
| Admin role step | D1 unreachable | Open to the seed floor only | Seeded owners retain access; nobody else gains any |
| Internal secret | Secret unset | Closed, 503 | Cron and article processing stall rather than running unauthenticated |
| Diagnostics | Secret unset | Closed, 403 | No probe can be run |
| Plan gate | D1 error | Closed, 503 | Paid features are unreachable, not free |
| Quota gate, config read | D1 error | Open | A suspended account is briefly not suspended, and tool kill switches lift |
| Quota gate, binding missing | MONITOR_DB unbound | Open, unmetered | The one gap worth alarming on: usage stops being counted |
| Quota gate, any other error | D1 error | Closed, 503 | Metered work is refused |
| Hourly rate limiter | Any error | Open | Abuse ceiling lifts; entitlement is unaffected |
| Seat check | Any error | Closed, 503 | Invites are refused rather than over-issued |
| Stripe webhook | Secret unset | Closed, 503 | No unverified event can change a plan; the reconcile job keeps entitlements correct |
Important: the two behaviors inside enforceAiQuota differ. A completely unbound MONITOR_DB allows requests through unmetered; every other database failure blocks with 503. If you are investigating unmetered usage, check the binding first.
Residual risk register#
These are true on main today. Document them as such and do not soften them.
| Item | Nature | Status |
|---|---|---|
| Body-trusted identity on most customer endpoints | An unverified caller can name another account's email and act as it. Workspace membership is still verified, so this does not grant workspaces the caller was never invited to | Partly closed. mvC1Identity is live on every resolving route, but MV_C1_ENFORCE is unset, so a route whose only check is _c1.denied still trusts the claim. Routes where the address selects data or spend now use mvC1Require, which does not wait for the flag; the rest are still open. See the C1 note above for which |
| API keys stored and compared in plaintext | A read of the key table yields working credentials | Hashing exists on an unmerged branch. Operational handling in API key security |
/share/<slug> renders stored report HTML unescaped, with no CSP | Stored script in a captured report executes on the main origin | A per-request nonce CSP exists on an unmerged branch. See Security headers and CSP |
| No CSP, HSTS, frame or nosniff headers on first-party responses | Standard browser-side defence in depth is absent | Open. See Security headers and CSP |
| Quota check and increment are not atomic | Concurrent premium requests can exceed a monthly allowance by up to concurrency × cost | Deferred deliberately: the atomic-reservation fix carries double-charge risk and needs a staged pass |
| Team invite tokens never expire, and accepting requires only the token plus the invited address | An intercepted invite stays valid indefinitely | Open |
GET /api/social/connect took the account from an unauthenticated ?email= query parameter | A connection flow could be started naming any address | Closed. /api/social/connect-url (POST) mints a single-use ticket against a verified session, and connect accepts only ?t=; the ?email= claim is gone. Mirrors the Google connect-url flow |
| Social and Google tokens stored without encryption | A database read discloses connected-account tokens | Open |
| Handlers returning raw exception text | Internal detail can reach a client in an error body | Open, low severity |
| Admin read endpoints are not audited | An admin can read customer data without a record | Open. Mutations are audited |
See also
Was this article helpful?
Thanks — feedback noted for the docs team.