Security Model
The platform's security rules: trust zones, deployment modes, what the perimeter enforces, what apps receive, hosted sign-up, MCP, secrets handling.
Mirrored from
docs/security-model.mdin the EveryApp repository, where it is edited.
The model behind this document, with its assets, actors, trust boundaries, the invariant each boundary holds and the test that pins it, is docs/threat-model.md. This document describes the mechanisms.
Trust Zones
- Client zone: untrusted browser, API, and webhook traffic.
- Gateway zone: the only public Worker; terminates Better Auth sessions (and, on third-party app hosts, per-host sessions), enforces app access, and injects signed identity.
- App zone: private Workers invoked by the gateway over service bindings,
or (hosted, third-party apps) Workers in a Workers for Platforms dispatch
namespace the gateway dispatches to. Either way through one seam,
getAppFetcher(packages/perimeter/src/getAppFetcher.ts). An app's helper Workers (its manifest'sworkers, deployed as<app worker>--<name>) are in the app zone as the app: the gateway never binds them, only the app's own Worker may (a service binding, or the Durable Objects and Workflows a helper defines), and a helper binds only the gateway's AppGateway, with the app's own props. The deploy checks each one against the same policy as the app's Worker (private, no routes or domains, only the app's own resources, Workflows named after the app's Worker) and refuses a built Worker the manifest doesn't declare. Helpers verify no identities: the gateway never calls one, so the app's Worker verifies the request and hands the helper what it needs (ids as headers, RPC arguments or Workflow params; Postiz's API helper, a port that is not part of the launch, reads the app'sx-postiz-identityheader). The rule that only the app's own Worker may bind a helper is what makes that safe, and a helper'sscheduledand Workflow code runs with no identity, as an app's does. A Hyperdrive binding is allowed only to a config the deploy provisioned for the app (<app worker>--<binding>, recorded in the app's resource ledger): created from, or adopted only when it connects to, the origin in the app's.everyapp/secrets.env, so binding it gives the app no database it couldn't already reach. The origin URL reachescfand drizzle-kit only in a private file or the environment, never argv or output. The dispatch tier refuses Hyperdrive (manifestTierProblems). An app deployed from a release (everyapp install <github url | tarball>) is held to the same rules: its tenant-neutral Build Output gets the same install rewrite and policy check as a local build, and its manifest the strict parser. The release's SHA-256 proves only that the tarball is whole, not who built it: whoever deploys a release trusts its publisher as they would trust the repository's code. The CLI never builds a repository from its URL (a repository without a release is cloned and deployed from its directory), so no one else's build scripts run on a deployer's machine.
Deployment Mode
The hosted tier is post-launch: the CLI's hosted-only surface (
setup --mode hosted,deploy --shared,register, deploy-key deploys) was removed on 2026-10-24 and is restorable from commit5ef30882; the contract isdocs/hosted-vision.md. The gateway side below is unchanged.
GATEWAY_DEPLOYMENT_MODE (a secret the CLI sends with the Gateway's first
upload, self_hosted, and never changes; ADR-0001) decides what the gateway
does, once, in
platformPolicy(env) (apps/every-app-gateway/src/server/platform-policy.ts):
self_hosted: invite-only sign-up, Cloudflare Access sign-in when configured, the setup claim, tenant admins manage their members' accounts, dynamic client registration 20 a day per address and 200 per gateway.hosted: our own install of the same gateway, with open sign-up into personal organizations and verified email, shared first-party apps, no Access sign-in (its settings are ignored and its plugin is not registered), the setup claim (the first owner's organization is the platform organization, named EveryApp), registration 200 a day per address and 20,000 per gateway, and tenant admins can't act on accounts (deactivate, reactivate, reset a password, delete one): removing a member keeps the account. Third-party deploys stay off behind a launch gate.- Anything else, unset included, is
invalid: every capability off, hosted and self-hosted alike: no setup claim, no Access sign-in, no app activation, no dynamic registration, no account actions, no outbound mail. Local dev builds default toself_hosted(cloudflare.config.ts).
Hosted sign-up
- Open sign-up starts only once the gateway is claimed (before that a
stranger's personal organization would make them an owner first). The
operator setting
HOSTED_OPEN_SIGNUP=falsekeeps it invite-only (any value but unset or"true"does, so a typo closes it); self-hosted is always invite-only. - An invite link, when a sign-up carries one, is checked as on self-host (live, same email, its secret not the invitation id), and an invitee gets no organization of their own: they join only the inviting one.
- A sign-up without one gets exactly one personal organization (named after
the person, else their email's local part; at most 64 characters and no
control or invisible formatting characters; slug plus 6 random
characters; marked
{"personalFor": <user id>}in its metadata) and an owner membership, written in one D1 batch. If it fails, the account is deleted and the sign-up fails: no account without its organization. Members still can't create organizations (allowUserToCreateOrganizationoff); a server-side sign-up (the setup claim) gets none. - Accounts sign in only once their email is verified. The verification link
(Better Auth's, a signed token in the query string, 24 hours) signs the
account in once. The setup claim's owner is verified by the claim. A
hosted gateway that can't send email refuses every sign-up with
EMAIL_NOT_CONFIGURED(503) before writing anything, and logsauth.sign_up_refused. Only a verified account may invite. - Every session starts in an organization when the user has one: their personal one, else the one their latest session was in (if still a member), else the first they joined. A session without one still falls back only to a sole membership, never a guess between several.
Outbound mail
- Every email (
verification,reset,invitation) counts against daily budgets per kind, per recipient (an HMAC of the address, never the address) and for the whole gateway, inrate_limit_counters, before it is sent: 5 per address and 2,000 / 1,000 / 2,000 per gateway when hosted, 10 and 1,000 each when self-hosted, none when the mode is invalid. A budget that can't be read or written sends nothing. Verification mail that can't go fails nothing: the resend answers the same whether or not it sent, so it doesn't tell whether an address has an account (Better Auth also limits it to 3 a minute per client). - Every value interpolated into an email is HTML-escaped, and kept to one
line in subjects and text (
server/email/templates.ts): organization names and inviter addresses are user input. - Apps send mail through the gateway's
emailcapability (server/capabilities/email.ts), never asend-emailbinding of their own (the deploy refuses one): the deployed manifest must listemailwithwrite, an agent's identity needsmcp:write, and app mail counts against its ownappbudgets, once per distinct recipient (100 per address and 5,000 per gateway a day self-hosted, 20 and 5,000 hosted; no per-organization budget). Recipients are the app's to choose. The From is the gateway's sender unless it matches one of the installation's senders (apps.email_senders,*@domainor one address, set witheveryapp email setby whoever can deploy, never by the manifest, so a release carries no domain); a hosted gateway refuses any app sender, so one organization's app can't send as the platform or as another's domain. Headers that would set the sender, recipients or subject (From,Sender,Reply-To, ...) are refused, values with line breaks too, and the rest pass through to Email Sending. Logs (email.app.send) carry counts, an HMAC of the first recipient and the outcome, never subjects, bodies, attachments or addresses. - Apps read their organization's members through the gateway's
memberscapability (server/capabilities/members.ts): the deployed manifest must listmemberswithread, the organization is the call's attribution (an organization's own app reads only its own), and the list holds only people the perimeter would let open the app (not deactivated, an active organization, granted an organization's own app). An agent's tool call is refused, so a connected agent can't page through the directory. Logs (members.app.list) carry the count, never an address. - Inbound mail reaches an app's
email()handler from an Email Routing rule that names its Worker, outside the perimeter: no identity, and anyone can mail the address, so the handler treats the message as untrusted input (itsFromis whatever the sender wrote) and its gateway calls name the organization withfor: { background: true, organizationId }; a reply sent from it goes through theemailcapability and its budgets like any other. The deploy keeps refusing theemailtrigger; the CLI makes the rules from the installation's inbound addresses instead (everyapp email set --inbound, and every deploy reconciles them), recorded in the ownership ledger, so it takes only an address no other app recorded and no foreign rule or catch-all delivers, never turns Email Routing on for a domain without a person's yes (or--enable-email-routing), and--purgeremoves only the routes the app recorded. The manifest only declares the handler (email: { inbound: true }), so an installer inherits no address. Apps on the dispatch tier declare no inbound mail (the hosted deploy API refuses it; docs/runbooks/email-sending-setup.md).
Outbound events
- Everything the gateway knows about (a member joins, an app is
installed, a cap is reached, a tool call is blocked, a token is revoked)
is one typed event on a bus inside the gateway
(
apps/every-app-gateway/src/server/events/; the catalogue's builders incatalogue.tsare the only writers of a gateway event'sdata, the type list isEVENT_TYPESinpackages/sdk/src/internal/events.ts; docs/design/2026-10-events-out.md). An owner or admin subscribes a URL, or the organization's owners and admins by email, to a list of types or patterns (member.*,app.crm.*,*): from Admin → Events (eight server functions under the admin middleware), from the CLI'seventsgroup, which writes the Gateway's D1 under the Cloudflare login (ADR-0003), or, once they land, from theeveryapp_workspace_events*tools. At most 25 subscriptions per organization. Subscriptions, events and deliveries are read and written under the acting organization only. - A webhook delivery is one JSON POST (
id,type,version,summary,time,gateway,organization,actor,data) to the subscription's stored URL, signed with its secret:X-EveryApp-Signature: t=<unix seconds>,v1=<hex>, the HMAC-SHA256 over<t>.<body>, besideX-EveryApp-Event,X-EveryApp-Event-Id,X-EveryApp-Delivery(stable across retries) andX-EveryApp-Attempt(signing.ts,deliver.ts). Every webhook subscription has a secret: one is generated (whsec_and 32 random bytes) when none is given and returned once by the surface that made it, and no list, page, log or later answer shows it again; a given one is 16 to 128 characters and is never echoed. The receiver recomputes the HMAC in constant time and refuses atmore than 300 seconds from its clock (the user docs carry the check in Node.js, Python and Go;verifyEventSignatureis the reference). Delivery is at least once; a receiver deduplicates onid. - The target is
https:to a public hostname: not an IP literal, not a loopback, local, internal or bare name, never the gateway's own host or a host under its app domains, no user name or password, no fragment, at most 2,048 characters (target.ts; the CLI applies the same rule when it adds one). With the Gateway settingEVENTS_ALLOW_PRIVATE_TARGETS=true(self-host only)http:, IP literals and private names pass; the gateway's own hosts never do. The rule runs again before every attempt, so a changed setting is honoured on the next one. The request follows no redirect (a 3xx is a failed attempt), times out at 10 seconds, and reads at most 1 KiB of a failed answer, keeping 200 characters as the delivery's error and never the body; a 2xx is delivered. Failures retry after 1 min, 5 min, 30 min, 2 h, 8 h and 24 h; the seventh failure, or a 410, is dead, shown as such in the Events tab and in the CLI, and an admin can redeliver it (the schedule starts over). The first attempt runs right after the event underwaitUntil; a per-minute cron trigger sweeps what is due, 50 rows a run, and the hourly one sweeps too. - The email sink takes no address: a subscription names a role, and each
delivery mails the organization's live owners (or owners and admins) as
read at delivery time, one mail per recipient within a delivery's
schedule (who was mailed is remembered per delivery and recipient for
three days, best effort, so a retry reaches only those still owed it; a
redeliver past that mails everyone again), through
the gateway's own sender as the
noticekind within its daily budgets (50 per address and 2,000 per gateway self-hosted, 20 and 2,000 hosted), with the catalogue's one-line summary, the fields a person reads and one link to the admin page (email-sink.ts). No sender configured makes the delivery dead withemail_not_configured; a spent budget retries. The first cap an organization sets creates a built-in "Cap alerts" email subscription (cap.warningat 80%,cap.reached) to its owners and admins; removing it disables it, so it is never recreated behind a person's back. - An app emits its own events with the SDK's
emit(POST /api/events/emitover itsEVERY_APP_GATEWAYbinding, attributed like a capability call: the request's identity, or a named organization for background work;app-emit.ts): the name becomesapp.<app id>.<name>, so no app can emit a gateway type;datais a JSON object of at most 8 KiB, nested at most 8 deep; 600 emits per app per hour, then 429 withRetry-After; not a provider call, so neither counted in usage nor refused by a monthly cap. Apps declare nothing in the manifest to emit. - Nothing in an event is ever a secret, a tool's arguments or result, a
mail body or a provider's answer: the builders carry a member as
{ id, email, name }and an app as{ id, rowId, name }, an invitation event its id, email and role and never the link, a token event the token's row id and name and never the value, tool events what the audit row carries; and every event'sdata, the gateway's own and an app's, is refused before any row is written when a key at any depth reads like a credential (secret, token, password, authorization, api key, private key, credential, cookie, session). Events and deliveries older than 30 days are deleted by the hourly maintenance. Logs (events.emitted,events.delivery,events.email_failed) carry ids, types, counts, status codes, up to 80 characters of an error code and a recipient by HMAC, never a secret, a URL's query or a body. The signing secret is stored as written in D1; docs/threat-model.md B9 says why and what that exposes.
Organizations and Shared Apps
The hosted tier is post-launch: the CLI's hosted-only surface (
setup --mode hosted,deploy --shared,register, deploy-key deploys) was removed on 2026-10-24 and is restorable from commit5ef30882; the contract isdocs/hosted-vision.md. The gateway side below is unchanged.
- Every request acts under one organization, and the identity JWT carries
it (
org_id,org_role). An organization whosestatusisn'tactive(the platformsuspendedit) opens nothing for anyone acting under it: its members' browser sessions and personal access tokens get 403 on every app host, its own apps and shared ones; its org-scoped control-plane server functions refuse withORGANIZATION_SUSPENDED; its AI connections and personal tokens are refused on the gateway's/mcp(403,organization_suspended) and it is offered no new connection. The status is cached per isolate for up to 30 seconds, like memberships. - An organization's own app (
scope = 'org', every app on a self-hosted gateway) opens for a member of that organization with the app installed for them: auser_app_accessrow, or membership in a group holding agroup_app_accessrow (one union inapps/every-app-gateway/src/server/repositories/effective-access.ts, read by the perimeter, the launcher, the members capability, the MCP catalogs and the consent page; removing a member ends both kinds of grant in one batch, and a group never holds another organization's user or app, nor a shared app). A browser session acting under another of the person's organizations is let in as their membership in the app's organization: the JWT carries the app's organization and their role there, never the session's. A personal access token only ever acts in its own organization (and, bound to an app, in that app only). - A shared app (
scope = 'shared', the hosted gateway's first-party apps) is a registry row of the platform organization (the install organization, "EveryApp"), at<appId>.<APP_BASE_DOMAIN>, on a service binding, one Worker for every organization. Any live member of any active organization opens it, with nouser_app_accessrow, acting under their session's organization: the JWT carries that organization, and the app scopes its data by it. OnlyplatformPolicy(env).sharedApps(hosted) honours the scope: elsewhere a shared row opens for nobody. Shared apps are listed in every organization's launcher, AI-connection consent and gateway/mcpcatalog, and read-only in other organizations' Admin → Apps; nobody manages their access, and only the platform organization's owners and admins edit or delete them. - The registration rules give a shared app only to the install
organization and never change an app's scope (a redeploy that names none
keeps the row's; the same id asked for as the other scope is refused,
tombstones included).
deploy --sharedandeveryapp registerwere removed with the hosted surface (2026-10-24). A row that is already shared still takeseveryapp deploy,email setandeveryapp deleteonly from a Cloudflare login that is an owner or admin of the platform organization by its verified email (SHARED_APP_FORBIDDEN; an API token is the account's own authority, as below).secret set|unsetchange the live Worker without a deploy since #321 and don't check that yet; the Cloudflare login they take can change the Worker's secrets through cf directly (TODO.md, Build). A shared app's manifest, the names undersecretsincluded, is read-only visible to every organization's admins in Admin → Apps (names, never values). A shared app's Worker gets noEVERY_APP_ORG_ID: nothing pins it to the platform organization. - Machine-path attribution: the AppGateway entrypoint decides which
organization a provider call (AI proxy or capability) is attributed to,
and logs where it came from (
orgSource):- An organization's own app (
scope = 'org': every self-hosted app, and dispatched apps) calls for the organization in its binding's upload-time props (props). A forwarded identity of that organization names the user; one that doesn't verify or names another organization is ignored (the call is the app's). Anx-everyapp-orgnaming any other organization is refused (403caller_org_mismatch); the directory of organizations is never consulted. - A shared app serves every organization. With a forwarded identity
(
x-everyapp-identity), the verified JWT'sorg_iddecides (jwt): an invalid or expired one is 401caller_identity_invalid, never a fallback to the platform organization. Without one, the call is background work (a cron, a Workflow step) and must name its organization inx-everyapp-org(header), else 403caller_identity_required; the organization must exist, beactiveand have at least one member who isn't deactivated, else 403caller_org_not_attributable(cached per isolate like callers: 30 seconds for a yes, 5 for a no; a lookup that fails is 500). With both, they must agree, else 403caller_org_mismatch. The header is trusted only because a shared app is first-party code we deploy; it names no user, so the call is the app's. - Browsers can't send it: the perimeter strips every inbound
x-everyapp-*(x-everyapp-orgincluded) before an app sees the request, and the gateway strips it before a provider does. - A capability's status (
GET /api/integrations/<id>) calls no provider and names no organization's data, so it needs no attribution. - The SDK's
createGatewayFetchandintegrationFetchtakefor: request(forward the identity) orfor: { background: true, organizationId }(sendx-everyapp-org, no identity), and in a shared app's Worker (EVERYAPP_SHARED=1) throwAttributionErrorfor a call that names neither. The gateway enforces the rule either way.
- An organization's own app (
- Per-organization usage, in any mode: every provider call (AI proxy or
capability) is attributed to an organization (from the props, the
forwarded identity or
x-everyapp-org, as above), logged with it and counted per organization, app and member (usage_daily,usage_monthly; Manage workspace → Usage). An owner or admin may cap an app's calls per month (org_app_settings.monthly_call_cap), set one default cap for every app without a cap of its own (org_settings.default_monthly_call_cap, migration0014_caps_defaults) and leave one app out of the default (org_app_settings.unlimited); each is null, unlimited, by default, and the app's own row wins over the default (a row holds exactly one of a cap orunlimited, both columns written in one statement; a row holding both keeps the app capped, which fails safe). The effective cap is read in one query (OrgAppSettingsRepository.getEffectiveMonthlyCap) and cached as before: the AppGateway entrypoint then refuses that organization's AI and capability calls to the app at the cap with429 app_monthly_cap_exceededand aRetry-Afterto the UTC month end, after attribution and before any provider call; a refused call is not counted; the refusal names the app, the cap (and the workspace default, when that is what binds) and the role that raises it (an owner or admin, under Manage workspace → Usage), never a member's name or address, since the body reaches the app's code and, through an MCP app's tool, a connected agent; a shared app's cap is per organization; a changed cap binds at once in the serving isolate and within 30 s (5 s from none) elsewhere, and a changed default clears the whole cache; a deleted app's caps go with it. With a cap set the check answers the month's count and the cap's source either way (checkMonthlyCap), and the entrypoint hands that standing to the event bus after the answer (events/cap-events.ts:cap.warningat 80%,cap.reachedandapp.paused_capat the cap, each once per organization, app, month and cap); the alerts themselves (owners and admins emailed at 80% and at the cap once the gateway can send mail; Manage workspace → Events) are the events system's, outside this path. With no cap set nothing bounds an organization's provider spend (metering, the wallet, is a later feature), so the hosted preview stays invite-only (HOSTED_OPEN_SIGNUP=false).
Operator Authority (Self-Host)
ADR-0003. In self_hosted mode, write access to the gateway's D1 database
in the gateway's Cloudflare account is operator authority.
- The CLI reads and writes the app registry (
apps,user_app_access,group_app_access,organization_keys), the install row, the deploy lease, the resource ledger and the owner setup link withcf d1 query, under the operator's own Cloudflare login or API token. The gateway issues no deploy credential and serves no deploy API; the one deploy path on the public Worker is the browser install (POST /api/admin/apps/install, #371): once the operator has given the gateway aCF_DEPLOY_TOKENandCLOUDFLARE_ACCOUNT_ID(everyapp setupasks; default off) andBROWSER_APP_INSTALLSisn'toff, a signed-in owner or admin of the install organization (the oldest with an owner, the one the setup claim made; any other organization is refused before the download and logged asapp_install.refused, and nobody installs before the gateway has an owner) can have the gateway download a public GitHub or oss.new release and upload it as an account Worker with that token, under the same release intake and policy subset as the hosted install (docs/runbooks/browser-app-install.md). That makes an admin session plus the gateway's token a second deploy authority; docs/threat-model.md B7 records it, and the token's account-wide reach, as open. - The registration rules live once, as pure functions in
@every-app/perimeter/registry, and the CLI runs them before every write: reserved and malformed app ids, the Worker nameevery-<orgKey>-<appId>(never one of the gateway's own names, at most 63 characters), D1 and KV names derived from it, the hostname, takeover checks against existing rows (a deleted app's tombstone keeps its names), and initial access (the deployer, owners and admins; everyone withaccess: "everyone"). The gateway still validates the manifest it reads from a row and treats an unknown status as disabled; a row whosetierisn'tservice_bindingordispatch, or whosescopeisn'torgorshared, is never routed (a 503 on its host), never read as another tier or scope. An MCP app's (tierprovider) row (everyapp mcp on: tierprovider, no hostname, no Worker) is left out of that lookup altogether: the gateway serves its tools itself on/mcp(docs/threat-model.md B5,toolbus.provider-app-in-process). A hand-written row that breaks a rule is the operator's doing, as a hand-edited Worker is. - The gateway activates apps itself: a
bindingapp (its Worker uploaded) becomesactiveon the first request that reaches the Worker over a service binding of the gateway version serving that request. Apendingapp is never bound or activated, a disabled or deleted one is never switched back on, and onlyGATEWAY_DEPLOYMENT_MODE=self_hostedorhostedactivates (any other value, unset included, fails closed). - Every CLI deploy records its actor on the app row (
deployed_by_actor, withdeployed_at): the Cloudflare login's email, orapi-token:<account id>in CI. Admin → Apps shows it; there is no deploy event log for them. A browser install is adeploy_jobsrow with the admin's email. - Revocation is Cloudflare's alone. Removing, banning or deactivating a
gateway member doesn't stop them deploying while they still hold the
Cloudflare login or an API token for the account. Any Cloudflare API
token with D1 write on the account is an operator, including the token an
app's CI uses to upload Workers; such a token could already redeploy the
gateway Worker or write
owner_setup_tokens. Compromise of the Cloudflare credential carries account-wide blast radius. - This trust model is not valid for the hosted tier (ADR-0001), which needs its own HTTP control plane and credentials: the deploy API for third-party apps ("Hosted Deploy API", below). A self-hosted gateway never serves it.
Perimeter Contract
- Routes are private unless the app manifest explicitly declares them public.
- An app with a required setting unset (
docs/manifest-settings.md) doesn't open: a private HTML navigation gets a 302 to${GATEWAY_URL}/?setup=<appId>, everything else, public routes included,503 app_needs_setup, naming no setting (apps/every-app-gateway/src/server/app-needs-setup.ts; docs/threat-model.md B1). Its tools stay out of/mcpuntil an admin runs thesecret setcommand the app lists show. - The gateway strips
Cookieand every inboundx-everyapp-*trust header on both public and private routes. Apps must never trust client-supplied EveryApp headers. - Private routes also strip inbound
Authorization, require a valid gateway session and app access, then receive a fresh RS256 identity JWT inx-everyapp-identity. - Public routes forward inbound
Authorizationso the app can implement its own bearer-token API or webhook authentication. The gateway does not interpret that header and never treats it as EveryApp identity. EveryApp's own bearers are never forwarded: a personal access token (epat_…) or a token of the gateway's OAuth server authenticates, or the request fails closed. - Anonymous public requests receive a signed public marker. A signed-in member may receive user identity on a public route only after the normal session, organization, app-access, and CSRF checks succeed.
- Failed CSRF checks deny private state-changing requests. On declared public routes they force anonymous handling so cookies cannot confer member identity to a cross-site request.
- TanStack Start server functions (
/_serverFn/*) only answer same-originfetch()calls from TanStack's client, on every method: navigations, anySec-Fetch-Siteother thansame-origin, and requests without the client'sx-tsr-serverFn/x-tsr-redirectheader are rejected before auth. The path is decoded the way Start decodes it (each valid%XXon its own when another escape is malformed). An app whose server functions live elsewhere (a router basepath or its ownserverFns.base) declares the path withserverFnBase; an app can opt out withserverFnGuard: falsein its manifest. - A cookie-authenticated WebSocket upgrade must come from the app's own
Origin; bearer-only upgrades are exempt. - The gateway strips an inbound
Cloudflare-Workers-Version-Overridesheader, so clients cannot choose which app version answers. - Cookie tossing: any host under the session cookie's registrable domain (a
sibling subdomain, or an app setting a host-only cookie on its own host)
can plant a second cookie with the session cookie's name, and the browser
sends both in an order the planter controls. A request whose
Cookieheader names the session cookie (better-auth.session_token, with or without__Secure-) more than once, however the copies are ordered or scoped, is refused with401 duplicate_session_cookiebefore anything is authenticated, on every app route (public ones included) and on the gateway's own host. A single copy works as before. Third-party hosts apply the same rule to their own session cookie,__Host-everyapp_s, which no sibling host can plant (__Host-cookies carry noDomain).
Control Plane
- The gateway's own host applies the same server-function rule and requires a
same-origin
Origin/Sec-Fetch-Siteon every cookie-carrying state-changing request (the token-authenticated/mcpand OAuth token and registration endpoints are left to their own checks; none of them reads the session cookie). Every response gets HSTS for the gateway host alone (noincludeSubDomains: the gateway often runs on the customer's apex domain),nosniff, frame denial,Referrer-Policy: no-referrer, andCache-Control: no-storeon admin, OAuth, setup and reset pages. A request carrying the session cookie twice is refused before any of this (see cookie tossing under the perimeter contract). - A fresh gateway has no owner and accepts no sign-ups or organizations until
it is claimed with the one-time setup link the CLI mints into D1 with the
operator's Cloudflare login. Apps may be deployed before then (a CI-first
install): the claim makes the first owner the owner of the organization
they were deployed to and gives them every app in it nobody has access
to. Members cannot create organizations (a hosted sign-up's personal
organization is the gateway's doing, above). On a gateway whose deployment
mode is invalid the claim is refused (
SETUP_UNAVAILABLE). - Invite links carry their own random secret (only its SHA-256 is stored); signing up through an invitation and joining with one both need it, never just the invitation id. Better Auth's accept, list-invitations and get-full-organization endpoints are closed over HTTP, so members are never sent pending invitations.
- Better Auth's admin plugin endpoints (
/admin/*: ban, impersonate, set a password, list or remove any user) are closed over HTTP in every mode: the gateway's admin pages go through its own server functions, which act only on members of the caller's organization and follow the platform policy. - The OAuth authorization server for MCP clients is on unless
MCP_OAUTH_ENABLEDis"false", the kill switch: then its endpoints and discovery documents return 404 and only personal access tokens (epat_) authenticate the gateway's/mcp. Seedocs/runbooks/mcp-oauth.mdand "Connected AI apps (OAuth)" below. - The AI proxy only forwards inference endpoints (with
store: falsefor responses and chat completions). The provider key is shared by every app, so anything else needs both the operator's allowlist (theAI_PROXY_EXTRA_ROUTESGateway setting, e.g./v1/files) and the app manifest'sai.routes;ai.modelscan restrict models. The upstream request carries the gateway's key and none of the app's credentials or account selectors (x-api-key,OpenAI-Organization,OpenAI-Project), no AI Gateway header (cf-aig-*), nox-everyapp-*header, no cookie and no forwarding headers. OpenRouter (/api/ai/openrouter) gets inference only:AI_PROXY_EXTRA_ROUTESandai.routesnever widen it, so its account-wide credits and keys endpoints stay closed. Workers AI (/api/ai/workers-ai) runs in the gateway's own account with itsWORKERS_AI_API_TOKEN, joined under/client/v4/accounts/<id>/ai(an app path can't leave it), and only its OpenAI-compatible/v1/chat/completions,/v1/embeddingsand/v1/responsesare open: the native/run/<model>, fine-tunes and the rest of Cloudflare's API stay closed, and dispatched Workers can't reachapi.cloudflare.comdirectly. The proxy's own refusals carryx-everyapp-errorand OpenAI's error body; the provider's errors pass through without it. - The capability proxy (
/api/integrations/<id>/<host>/<path>on the AppGateway binding,docs/capabilities.md) puts a key the gateway holds on calls only for an app whose registry row's manifest lists the capability, only to that provider's own https hosts, with no redirect followed, and only with the methods the provider reads with (or writes with, for an entry that asks foraccess: "write"on a provider that offers it; an agent's identity needsmcp:writefor those). The key is shared by every app in every organization, so paths that list or describe the whole provider account (DataForSEO'stasks_ready,tasks_fixed,id_list,errors,appendix/user_data,appendix/webhook_resend) are refused. The upstream request carries none of the app'sAuthorization,x-api-key, cookies,x-everyapp-*or forwarding headers, and the response none of the provider's cookies. Only an app that lists a capability learns whether the gateway holds its key. An organization's own app's call is attributed to the person whose identity token the app forwards when it verifies for the app's organization, otherwise to the app; a shared app's calls follow the attribution rule under "Organizations and Shared Apps". - The providers come from a registry: the built-in catalog
(
packages/sdk/src/internal/capabilities.ts), then the API-key providers an operator registered on this gateway as rows of its D1providerstable (everyapp provider add, under the Cloudflare login, ADR-0003; the gateway only reads the table, and no MCP tool writes it yet). A row is a new host to send a secret to, so what a row may say is checked when it is written and again every time the gateway reads it (packages/sdk/src/internal/providerRegistry.ts,apps/every-app-gateway/src/server/providers/index.ts): every setting it names starts with its own id and is nobody else's (never the gateway's signing material, a catalog provider's key or another row's), its hosts are public DNS names the gateway doesn't serve itself, it never takes a catalog id, and its key is read only while the row has the hosts and auth it had wheneveryapp secret setset it, so rewriting a row never moves a key the operator set. A row that fails is left out and loggedprovider.row_invalid. A row is gateway-wide like every key here, so a provider whose data is a whole account (data: "account") is refused on a hosted gateway. The register'sproviders.*rows (docs/threat-model.md B4) are these rules. - Social networks' OAuth clients (
oauth_clientcapabilities,server/capabilities/oauthClient.ts) are the operator's, one per network for every organization. The gateway completes only each network's token endpoints (host, exact path, method), only for a user's own grants (authorization_code,refresh_tokenand the networks' token exchanges:client_credentials, or any grant it doesn't know, is refused withintegration_grant_not_allowed, since it would mint a token for the gateway's own application rather than a person's), for an app whose manifest lists the network withwrite, refuses agents' identities outright (integration_agent_not_allowed,mcp:writeor not: an agent can't connect accounts or mint tokens), removes any client id, secret orAuthorizationthe app sent (query, body, header, in any case) before it puts its own where the endpoint takes it, buffers the body (64 KiB, form or JSON only), follows no redirect and drops the network'sset-cookie. For a client with scope sets (Google, Microsoft, Slack) it also holds each app to the sets its manifest lists at every mint and refresh: a void grant, a grant type the policy doesn't complete, a requestscopeoutside the sets and a 2xx answer whose granted scopes exceed them are refused (integration_scopes_undeclared,integration_grant_not_allowed,integration_scope_not_allowed,integration_scope_exceeded; an answer naming no scopes isintegration_scope_unverified), the over-scoped tokens dropped rather than forwarded, stored, logged or revoked. That boundary is which tokens an app can obtain, not what it does with them: API calls don't pass through the gateway, a Google token is narrow per app only when the app asks for the status's scopes withoutinclude_granted_scopes, and a shared Microsoft or Slack client's wider consent is refused rather than narrowed. The client secret never reaches an app; the publicclientIdreaches only an app that lists the network. Logs name the grant type from a known list, never the query, body or answer, which carry codes and tokens (on a scope refusal, the declared set names and the scope names in excess). The access and refresh tokens the exchange returns are the app's, stored and used by the app. X stays an app secret: OAuth 1.0a signs every call with the user's token secret, which the gateway never holds.
Sign-in with Cloudflare Access
Optional in the code, strongly recommended in front of every self-hosted
gateway at launch (docs/runbooks/cloudflare-access.md, everyapp access on; docs/threat-model.md says what it adds and what it doesn't cover),
and only on a self-hosted gateway: elsewhere the plugin is not registered and the Access settings are
ignored (passwords alone, no outer ring). Better Auth stays the only
session system: Cloudflare Access, as an OIDC provider or as an outer ring in
front of the gateway's hostnames, only proves who someone is, and a Better
Auth plugin (apps/every-app-gateway/src/auth/cloudflare-access/) turns that
into an ordinary Better Auth session. Everything downstream is unchanged.
- Settings are Worker secrets only, never the admin UI.
- The outer ring's
Cf-Access-Jwt-Assertion(orCF_Authorizationcookie) is never trusted as such: it must be an RS256 JWT signed by a key in the team's JWKS (cached ten minutes, refetched for an unknown key id at most every 30 s, never used past its lifetime: a fetch failure signs nobody in; nobody waits on a fetch for more than 5 s, so one stuck in a cancelled request can't hold up the isolate), with the configured issuer and AUD, an expiry (60 s of skew),type: "app"and an email (service tokens have none). - It signs people in only on the gateway's own host, and only with the
gateway application's AUD (
CLOUDFLARE_ACCESS_AUD); the apps' separate application (CLOUDFLARE_ACCESS_APPS_AUD) only vouches for sessions, so an app host's Access cookie is never a gateway login. The runbook asks for HttpOnly and the binding cookie on both. - Behind the outer ring a session cookie counts only with a verified
assertion for the same person on that request (the header, or the
CF_Authorizationcookie browsers also send on bypassed paths): in the perimeter on app hosts, server functions, the OAuth pages and Better Auth's own endpoints (whose requests have such a session taken out). Sessions are only made for that person (a password or OIDC sign-in as anyone else is refused). So a person taken out of the Access policy loses browser access when their Access session ends. Access bypasses only machine endpoints (/mcp*,/oauth/token,/oauth/register,/.well-known/*, apps' public routes) and Sign in with Apple's cross-site POST return (/api/auth/callback/apple, which only redirects to the same URL as a GET; "Sign-in providers" below), never/oauth/authorizeor the consent pages. Bearer credentials (personal access and OAuth tokens) never pass through Access: offboarding is deactivation. The CLI's deploys never go through the gateway (it reads and writes D1 and Cloudflare's API), so nothing is bypassed for it;setupanddeployonly probe a hostname over HTTPS to see that it answers, and take Access's login page as an answer. - OIDC sign-in goes through Better Auth's own state and PKCE (S256) with the client secret; the ID token is verified in full (signature by the application's JWKS, issuer, audience = client id, expiry); posted ID tokens are not accepted as a sign-in; only the ID token is stored.
- Nothing plants OAuth state in a browser but a sign-in that browser started: the gateway registers no plugin that sets the state cookie from a link (Better Auth's Expo plugin did, through its authorization proxy; it went with the mobile app), and its only trusted origin is its own URL, so no custom-scheme client is exempt from the origin checks.
- Both identify people by the verified email. Linking to an existing account follows Better Auth 1.7's rules: the provider is trusted and its email verified, and the existing account's own email must be verified (or the link is made while signed in to it), so an account registered with someone else's address is never handed over.
- Membership stays the gateway's: members (never deactivated ones), pending invitations for the verified email (accepted), operator-listed auto-join domains (members with the apps available to everyone, as an invitation gives), and through the outer ring only, anyone Access admits if the operator opts in. Nothing happens before the gateway is claimed. Removing someone from the Access policy is not offboarding (tokens and AI connections don't pass through Access): deactivation is, and for people auto-join covers, "Delete" deactivates instead, since a deleted account would just join again.
- The perimeter strips every inbound
cf-access-*header (and, as always, the cookie header), so apps only ever see the identity JWT. EMAIL_PASSWORD_SIGN_IN=falsecloses password sign-in, sign-up and resets over HTTP only while Access sign-in is configured and an owner can sign in with it; setting it back totruereopens them.
Sign-in providers
"Continue with Google" (Microsoft, GitHub, Apple, Cloudflare, or any
OpenID Connect issuer) beside passwords, in both shapes
(apps/every-app-gateway/src/auth/sign-in-providers/, the catalog in
packages/sdk/src/internal/signInProviders.ts, the runbook
docs/runbooks/sign-in-providers.md). The same shape as Access sign-in:
the provider only proves who someone is, Better Auth's own /sign-in/social
and /callback/<id> run the flow with state and PKCE, and a gateway plugin
applies the membership rules through database hooks, so everything after
the session is the same as after a password.
- Settings are Worker secrets only (
<ID>_SIGNIN_<FIELD>, a family no registered provider row or app manifest can take), set under the Cloudflare login witheveryapp signin addorsecret set; nothing in the admin UI, no server function and no MCP tool writes one, so a stolen session or token can't add a way in. A half-set provider is off. - Identity scopes only (
openid email profile; GitHub'sread:user user:email; Apple'semail name): no provider token the gateway holds can act on anyone's behalf, and no refresh token is kept. - The email must be verified by the provider: Google's and Apple's
email_verified, GitHub's verified primary address, the OIDC ID token's (or userinfo's)email_verified, and for Microsoft the pinned tenant's own word or, on the multi-tenant endpoints,xms_edov,email_verified,verified_primary_emailor a personal account (any tenant's admin can put any address on their users). Cloudflare's/userreports no verified flag: its address counts on the strength of the OAuth client being private (only the operator's account members can authorize it, and they joined that account by accepting an invitation sent to the address), the same trust the Access "Sign in with Cloudflare" identity provider rests on with "Restrict to account members" on; the setup guide says to keep the client private. An unverified email signs nobody in and creates nothing. - Who gets in is the invitation flow's rule: an existing member (never a
deactivated one), or a pending invitation for that exact email, accepted
with its role and the organization's default apps; on the hosted
gateway's open sign-up, anyone with a verified email, with a personal
organization as the sign-up form gives. Nothing before the setup claim:
the first owner is still made by
everyapp setup. No auto-join by email domain (TODO.md, "Sign-in providers (2026-10-25)"). - Accounts are keyed by the provider's stable subject (
sub, GitHub'sid, Entra'soid). Linking an identity to an existing account with the same email follows Better Auth's rules for an untrusted provider: the provider reports the email verified, and the account's own email is verified too, else the link is made while signed in to it (the account menu's "Connect <provider>",/link-social, which also refuses another email). So an account registered with someone else's address is never handed over, and a password owner from the setup link connects once. - The generic OIDC provider fetches discovery when a sign-in starts
(cached an hour, never at startup), requires the document to name the
configured issuer, verifies the ID token in full (RS256 by the issuer's
JWKS with the Access verifier's fail-closed cache, issuer, audience,
expiry, and the nonce the sign-in started with), takes a userinfo answer
only for the same
sub, and offers no sign-in by posted ID token; none of the providers do. - The Access ring is unchanged and still in front: behind the outer ring a
provider sign-in makes a session only for the person Access vouches for
on the request, and the Cloudflare Access settings keep their own family
(
CLOUDFLARE_ACCESS_OIDC_*for Access for SaaS;OIDC_SIGNIN_*is for another issuer). EMAIL_PASSWORD_SIGN_IN=falsenow also takes effect with a sign-in provider on, once an owner has connected one.- Apple returns with a POST (
response_mode=form_post); Better Auth answers it with a redirect to the same URL as a GET, which the outer ring then checks, soeveryapp access onleaves/api/auth/callback/applepublic. That POST creates nothing.
Agent Tools (MCP)
- Two endpoints can serve the same tools: the gateway's own
/mcp, one connector for every app a user reaches (tools named<app>_<tool>, plus the read-only built-inseveryapp_whoamiandeveryapp_list_apps), and, only where an app's manifest setsmcp: { by: "gateway", why }, that app host's/mcp, for that app alone under its tools' own names. By default an app host serves no MCP: its/mcpis a private route like any other, and a token whose scopes are allmcp:scopes opens nothing there. Both are served by the gateway's ToolBus from the tool catalogs it snapshots in D1 when an app is activated, when a hosted deploy goes live, when the app's host sees it run a new version, when the consent page shows an app without one, and within a/mcprequest that finds none (apps declare them withdefineTools()). Listing tools serves those snapshots; the gateway calls an app only to take one it lacks (at most 20 apps within a request, for 3 s, with the one-minute system token that opens nothing but the catalog). Each tool call is one call over the app's service binding. The gateway's/mcpalso serves MCP apps (provider apps in the code;everyapp mcp on dataforseo: tierprovider, toolsdataforseo_<tool>) in-process with the Gateway's key, each call under the capability proxy's rules and the organization's monthly cap, counted and audited against the provider row (docs/capabilities.md, "MCP apps"). - The gateway's
/mcptakes an OAuth access token issued by the gateway (see below) or a personal access token, never a cookie; a self-hosted gateway behind Cloudflare Access's Managed OAuth also takes a verified Access assertion for the person (docs/runbooks/cloudflare-access.md; checked in tests, not yet against a live Managed OAuth application, and the service-token path that keeps personal tokens working there is unverified live: TODO.md, "MCP through Cloudflare Access"). It serves the active apps of the caller's organization they have access to, narrowed to the apps an OAuth connection chose or the one app a token is bound to. A browser may call it only from the gateway's own origin or an origin listed inMCP_ALLOWED_ORIGINS(empty by default); behind Managed OAuth, where Access vouches for a browser's cookie too, that rule is checked before anything is read for the person. - An app host's
/mcp, where the manifest serves it, needs a personal access token or the user's browser session; it takes no OAuth token and advertises no authorization server (its/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-serverand/.well-known/openid-configurationpaths, the same three under/mcp/.well-known/, and anything under them, answer 404 and are never proxied, even under a public/.well-known/*or/mcp/**route). Token scopes set access:*ormcp:writeis read & write,mcp:readis read only, anything else is refused with403 token_scope. A browser session is read & write. Destructive tools are hidden and refused for everyone; write tools are hidden from read-only callers. - A browser session reaches
/mcponly from the app's own pages: it is intended (an app may call its own tools), and the usual CSRF check guards it. A cookie-carrying POST needsSec-Fetch-Site: same-origin, or without that header anOriginon the app's host; a sibling app's page (same-site) or any other site gets403 csrf_deniedbefore a tool runs. Only a POST makes the gateway call the app (to sync a missing or outdated catalog), so the GETs a sibling page can send with the cookie never do. - A token whose scopes are all
mcp:scopes is MCP-only: every app route other than a/mcpthe host serves as MCP (public ones, every route of a host withoutmcp, and a/mcpa public route covers on aby: "gateway"host included) answers it403 token_scope, so anmcp:readtoken stays read only even though apps don't check scopes on their own routes. Tokens holding*(what a token created without scopes gets) or any non-MCP scope keep working on every route. - Each call carries a fresh identity minted for that call alone:
chan: "mcp", explicitmcp:*scopes (never*, nevermcp:destructive), and atcclaim naming the tool and call id. The SDK refuses a tool call without it, or whose scopes don't cover the risk the tool declares. - The internal tools endpoints (
/__everyapp/tools/listand/call) answer 404 to every outside request, however the path is encoded. The catalog endpoint only accepts the gateway's one-minute, one-purpose system token (x-everyapp-system), which is never an identity and can't call tools. - A browser may call an app's
/mcponly from the app's own origin; any otherOrigingets 403. Cross-origin browser clients (MCP Inspector's direct mode, say) aren't served on app hosts: their CORS preflight carries no credentials, so the perimeter answers it 401.everyapp devanswers preflights from localhost origins, so the Inspector works locally. - The gateway checks a call's arguments against the snapshotted schema
before calling the app, in bounded time: it never runs an app's regular
expressions (
pattern,patternProperties), and leaves recursive schemas and arguments with more than 1,000 values to the app, whose SDK validates every call with the tool's own schema. Arguments nested more than 64 levels deep are refused, JSON-RPC batches are refused, and an app gets 30 seconds (headers and body) to answer a call. The compiled checks are cached per registry row (never per app id, which two organizations' rows share) and catalog hash, and reused only for the exact schema they were built from, since the hash is the app's own claim. - Each tool call, on either endpoint, logs one
mcp.tool_callline and writes onemcp_tool_callsrow with a keyed digest of the arguments, never the arguments or the result, including calls refused for their arguments. Admin → Activity shows them to the organization's owners and admins. An hourly cron deletes rows older thanMCP_AUDIT_RETENTION_DAYS(90 by default).
Connected AI apps (OAuth)
- The gateway is an OAuth 2.1 authorization server
(
@cloudflare/workers-oauth-provider1.2) for exactly one protected resource,${GATEWAY_URL}/mcp, whose metadata names that exact URL. Tokens are bound to it and work nowhere else: an app host refuses them, and never forwards one to an app, even on a public route. Every client must use S256 PKCE; redirect URIs must be https or loopback http, and neither a client id nor a redirect URI may be on the platform's domains (the gateway's host,APP_BASE_DOMAIN, or any host under either: every host the gateway answers): the authorization endpoint, the consent page and the token endpoint all refuse one before anything is fetched. An invalid authorization request is answered on the gateway's own page, never redirected to the client: registration is open, so a redirect could go anywhere. - Clients identify themselves with a Client ID Metadata Document (an https
URL on a domain they control, which the consent page shows as verified
only when access goes to that domain, a subdomain of it or an app on the
user's computer; otherwise it warns that access goes to a different site,
since anyone can host a file on some big domains) or register dynamically (their name is shown as unverified, without
control characters and clipped). Metadata documents are fetched under the
global_fetch_strictly_publiccompatibility flag, which routes every fetch as a request from the internet would go, through Cloudflare's front door, never straight to an origin behind a zone. That doesn't keep a fetch away from the gateway's own zone: its front door is the gateway itself, so a document served from an app's public route would come back as a "verified" client. That is why client ids on the platform's domains are refused, not fetched. A dynamic registration that names no token endpoint authentication method is registered as a public client. - Dynamic registration is anonymous, and each one is a write to the
account's KV, whose writes every namespace shares. So it keeps only what
the gateway uses (at most 16 KiB in, the name cleaned and clipped, ten
redirect URIs), for 60 days without use, and is limited to 5 a minute
per client address in each isolate, 20 a day per address and 200 a day
per gateway when self-hosted, 200 and 20,000 on the hosted gateway, none
when the mode is invalid (counted in D1, where a refused request writes
nothing).
MCP_DCR_ENABLED=falseturns it off for gateways whose users only need clients with metadata documents. - Consent:
/oauth/authorizevalidates the request before anything else and sends a signed-out user to sign in first. The consent transaction is single-use, expires in ten minutes and is bound to the browser that started it by a__Host-cookie; the decision is a same-origin form POST from a signed-in user. Consent pages are never cached or framed and send no Referer. The page shows where access will be sent, warns when that is an app on the user's computer, and lets the user choose the organization, all apps (including future ones) or chosen ones, and read & write or read only. - Each approval is an
mcp_connectionsrow; the tokens carry only its id, the user's and the organization's. Every/mcprequest re-reads the row, the user's membership and role, their ban state and their app access, so revoking a connection, removing or deactivating the member, or removing an app takes effect on the next request; a refresh for a connection that no longer stands fails withinvalid_grantand revokes the grant. Read only connections never see write tools. - Users list and revoke their connections at
/settings/connections. Removing a member revokes their connections in that organization, and deactivating a user revokes all of theirs, grants included. Connecting the same client again in the same organization replaces the earlier connection there and its grant (for a metadata document client, the same installation's: same redirect URI, a loopback redirect's port not counting); a member of two organizations keeps one connection in each. - Refresh tokens rotate on every use, but the library accepts the token
before the latest one until the latest is used (so a client that lost a
refresh response can retry), and it has no reuse detection: a replayed
refresh token never revokes its grant. A stolen refresh token can
therefore mint access tokens alongside the client for a while.
@cloudflare/workers-oauth-provider1.2.3 has no option for stricter rotation. Revoking the connection, removing the member or deactivating the user ends it at once, since every refresh re-checks the connection.
App Verification Contract
- Apps verify the signature, issuer, audience, token type, and key id before
using identity (a public marker's issuer is checked when
EVERYAPP_IDENTITY_ISSUERis set, which every deploy sets). The production identity key id iseveryapp-identity. - The audience is the Worker name. Every token the gateway mints for an
app (user identities and public markers in the perimeter, ToolBus call
identities and catalog system tokens) carries
aud= the registry row'sworker_name(every-<orgKey>-<appId>), never the app id. A deployed app expects exactly that:everyAppWorker()declaresEVERYAPP_AUDIENCEas the Worker's name (a placeholder the deploy installs as the registry's name), the CLI refuses an installed build whoseEVERYAPP_AUDIENCEis anything else, and the SDK answers 500 when it is unset. So a token minted for one organization's copy of an app is refused by another organization's copy of the same app id. The AppGateway entrypoint verifies a forwarded identity against the calling Worker's name (its upload-timeworkerNameprop) too. - Only under the dev opt-in (
EVERYAPP_DEV, which onlyeveryapp devsets) does the SDK accept the app id as the audience whenEVERYAPP_AUDIENCEis unset: the dev gateway and dev ToolBus mint for the app id, signed with the public dev key, from the dev issuer alone. - Caches of minted tokens and compiled tool schemas are keyed by audience or registry row id, never by app id alone.
- User identity is organization-bound:
org_idis the organization the request acts under, and the person is a live member of it. For an organization's own app that is the app's organization (a session acting under another of the person's organizations is let in only as their membership there); for a shared app it is the session's organization. Anything else is denied (see "Organizations and Shared Apps"). - Missing, invalid, expired, or wrong-audience identity fails closed.
Dispatch Tier and Egress
The hosted tier is post-launch: the CLI's hosted-only surface (
setup --mode hosted,deploy --shared,register, deploy-key deploys) was removed on 2026-10-24 and is restorable from commit5ef30882; the contract isdocs/hosted-vision.md. The gateway side below is unchanged.
The hosted gateway reaches third-party apps (registry tier = 'dispatch')
in a Workers for Platforms dispatch namespace. Third-party deploys are off
unless a hosted gateway's THIRD_PARTY_DEPLOYS secret is exactly on
(platformPolicy.thirdPartyDeploys, "Hosted Deploy API" below), and the
gateway's release config declares no namespace yet: the hosted install adds the DISPATCHER
binding once the account has one. Until then every dispatch app answers 503.
getAppFetcheris the only code that invokes an app. For a dispatch app it reads the optionalDISPATCHERbinding and throwsAppUnreachableError(503app_unreachable, never another route) when it is absent; re-checks that the row's Worker name has the registry's shape for the app id (every-<orgKey>-<appId>, not a gateway name), so the registry can never dispatch to a script outside that naming rule (the org key is read from the name; checking it against the row's organization is to come before third-party deploys open); and callsDISPATCHER.get(workerName, {}, { limits, outbound })with per-invocation limits (by default 5,000 ms of CPU and 200 subrequests) and the outbound parametereveryapp= the app id, organization, Worker name and egress allowlist. A script the namespace doesn't hold ("Worker not found") is a 503, not an app error. Self-hosted apps keeptier = 'service_binding'and are reached exactly as before; neither tier ever falls back to the other.- The gateway's
Outboundentrypoint (apps/every-app-gateway/src/server/outbound.ts, exported fromserver.ts, no routes) is the namespace's outbound Worker: everyfetch()a dispatched Worker makes to the internet goes through it. It denies by default: a request goes out only over HTTPS to an exact hostname on the app's allowlist (empty until the manifest declares one, so today everything is denied). It always denies the providers whose keys the gateway holds (api.openai.com,openrouter.ai,api.dataforseo.com,api.context.dev,gateway.ai.cloudflare.com,api.cloudflare.comfor Workers AI and the rest of Cloudflare's API, and their subdomains:reserved_host), the token endpoints of the OAuth clients the gateway holds, however the path is spelled, though not those networks' API hosts, which apps call with their users' tokens (reserved_endpoint; Mastodon's onMASTODON_URL), and the gateway's own hosts (GATEWAY_URL's host,APP_BASE_DOMAINand every app host under it), every third-party app host (THIRD_PARTY_DOMAINand every name under it, as set), and refuses everything when the dispatcher passed no app. Each denial is a 403 to the app and anegress.deniedlog line naming the app, organization, Worker, host and reason. Service bindings (the AppGateway binding included) never pass through it, so apps reach models and data providers only through the gateway's metered proxies.
Third-Party Hosts and Per-Host Sessions
The hosted tier is post-launch: the CLI's hosted-only surface (
setup --mode hosted,deploy --shared,register, deploy-key deploys) was removed on 2026-10-24 and is restorable from commit5ef30882; the contract isdocs/hosted-vision.md. The gateway side below is unchanged.
Two classes of app host, decided from the Host header before any registry
lookup or credential read (routeHost,
packages/perimeter/src/hostRoutes.ts; the profile comes from
hostProfile in apps/every-app-gateway/src/server/app-hosts.ts):
| First-party hosts | Third-party hosts | |
|---|---|---|
| Names | <appId>.<APP_BASE_DOMAIN>, <appId>-<orgKey>.<APP_BASE_DOMAIN> (every non-gateway host, looked up by full name) |
<appId>-<orgKey>.<THIRD_PARTY_DOMAIN>, one label below it |
| Rows served | tier = 'service_binding', scope org or shared |
tier = 'dispatch', scope = 'org' |
| Code | Workers our account (or the self-hoster) deployed | customer code in the dispatch namespace |
| Browser credential | the Better Auth cookie, Domain=<APP_BASE_DOMAIN> |
__Host-everyapp_s, host-only, one per host |
| Personal access tokens | yes | yes, same rules (own organization, bound app) |
- The class decides the row. A first-party host never serves a
dispatch row and a third-party host never serves a service-binding or
shared row (
hostAdmitsApp): such a row answers 503app_unavailable, so a request carrying the shared session never reaches customer code, and a per-host session never opens a first-party app. Under the third-party domain, a host that is not one app label (the apex, deeper names) or has no row is a 404 without falling back to anything. - The cookie boundary.
THIRD_PARTY_DOMAINmust be another registrable domain than the gateway's (everyapp.devbesideapp.everyapp.com):thirdPartyDomain(env)refuses one whose last two labels equal those ofGATEWAY_URL's host orAPP_BASE_DOMAIN(with no public suffix list that is the sound test: names that differ there are different sites; ones that share them might not be, so they are refused), and is null on a self-hosted or invalid-mode gateway whatever the setting says. Refused or unset, there are no third-party hosts, the handoff routes don't exist, and the deploy API names no hostname. So the browser never sends the Better Auth cookie to a third-party host, and the cookie'sSameSiteprotection holds between customer code and the control plane. The third-party authenticator (apps/every-app-gateway/src/perimeter/hostSessionAuthenticator.ts) never reads the Better Auth cookie either, and the first-party one never reads__Host-everyapp_s; the control plane reads only its own cookie. - The handoff (
apps/every-app-gateway/src/server/host-session.ts):- An unauthenticated page navigation on a third-party host gets a random
32-byte nonce in
__Host-everyapp_n(host-only,Secure,HttpOnly,SameSite=Lax, 10 minutes; reused while present, so tabs share it) and a 302 to<gateway>/app-session?return_to=<page>&nonce_hash=<SHA-256>. Other unauthenticated requests get 401 as on first-party hosts. GET /app-sessionon the control plane sends a signed-out browser to sign in and back; signed in, it answers a page that posts the handoff toPOST /app-session/startfrom the gateway's own origin (the control-plane guard refuses a cross-site POST that carries the cookie; the page's CSP allows only its own script and a form action to the gateway and the app's origin).POST /app-session/startre-checks thatreturn_tois https on a third-party host, that the host's registry row is a dispatch app that isn't disabled, and that the signed-in person is a member (not deactivated) of its active organization with the app installed for them; otherwise 400, 404 or 403 and no code. It answers 303 tohttps://<host>/__everyapp/session?code=…: a MAC token (HMAC-SHA256, key fromBETTER_AUTH_SECRETthrough HKDF under its own label) naming the gateway session id, the user, the app row, the host, the nonce hash and the page, valid 60 seconds, with a single-use id.- The app host's perimeter answers
/__everyapp/sessionitself (never the app): the code must verify, be a code (never a session token), be unexpired, name this host and this app row, and match the hash of the browser's nonce cookie, so nobody can sign a victim in to the attacker's account by sending a code; then its id is claimed once in D1 (rate_limit_counters, an insert that only one request wins). It sets__Host-everyapp_s(a MAC token bound to the gateway session id, user, app row and host, 24 hours), clears the nonce and 303s to the page path. Anything else is a 400 page with no cookie. Both answerReferrer-Policy: no-referrer, so neither the redirect nor the 400 page's link back to the app sends the app the code's URL asReferer.
- An unauthenticated page navigation on a third-party host gets a random
32-byte nonce in
- Every request re-checks.
__Host-everyapp_sis verified (signature, type, expiry, host, app row), then, live: the gateway session row still exists and hasn't expired, the user isn't deactivated, they are a member of the app's organization (the identity JWT carries that organization and their role there,audthe Worker name), the organization is active and the app is installed for them. Signing out of the gateway, an admin revoking sessions, removing the member or their access ends the host session on the next request (memberships and organization status are cached per isolate up to 30 seconds, as on first-party hosts; the session row is read every time). A request naming__Host-everyapp_stwice is refused (duplicate_session_cookie). A bearer that isn't a personal access token authenticates nobody here and never falls back to the cookie. - What an app may not say. On third-party hosts the perimeter removes
Access-Control-Allow-Credentialsfrom app responses (sibling apps on the same domain are same-site, so a credentialed CORS grant would let one read another's pages with the visitor's session) and everySet-Cookiewhose name starts with__Host-everyapp_in any case (only the perimeter sets the session and nonce), WebSocket handshakes (101) included.Domainis stripped from app cookies everywhere, so they stay host-only (on third-party hosts, from 101s too). State-changing requests must be same-origin (Sec-Fetch-Site: same-originor a matchingOrigin), so a sibling app's page can't post with the visitor's session. - Self-hosted gateways have no third-party domain: every host but the gateway's own is first-party and looked up as before, the handoff paths are the control plane's and the app's as before, and only a dispatch row (which no self-hosted deploy writes) is refused.
- Third-party hosts reach the gateway Worker through a Workers route
*.<THIRD_PARTY_DOMAIN>/*on that zone, added by hand on the hosted install (docs/runbooks/hosted-install.md), never a Custom Domain per app.
Hosted Deploy API
The hosted tier is post-launch: the CLI's hosted-only surface (
setup --mode hosted,deploy --shared,register, deploy-key deploys) was removed on 2026-10-24 and is restorable from commit5ef30882; the contract isdocs/hosted-vision.md. The gateway side below is unchanged.
Third-party apps (customer code, forks of OpenSEO included) reach the
hosted gateway's dispatch namespace only through its deploy pipeline
(apps/every-app-gateway/src/server/deploy/): the deploy API (deploy
keys; the CLI's path to it left on 2026-10-24) and an admin's install from
GitHub.
- The launch gate. The routes (
/api/v1/…on the gateway's host) exist only whileplatformPolicy(env).thirdPartyDeploysholds: a hosted gateway whoseTHIRD_PARTY_DEPLOYSsecret is exactlyon. Otherwise the paths are the control plane's like any other (404), and no deploy key can be made. The CLI never sets the gate; it is opened by hand, and stays shut until the isolation set (limits and theOutbounddefault-deny on every dispatch, this allowlist and its read-back), identities minted for the Worker's name (aud = workerName), per-host sessions on the separate third-party domain and the egress default-deny are verified against a live namespace. - Deploy keys. A personal access token with the
deployscope and nothing else (*doesn't count), bound to one organization and optionally one app, 30 days by default. Every request checks it live (its scopes included: a row holding any scope besidedeployis refused where it is used, not only where keys are made): unknown, revoked or expired is a 401; its owner must still be a member (not deactivated) and an owner or admin of that organization, which must not be suspended; a path naming another organization is a 404, and an app-bound key deploys only its app. The API reads no cookie. A deploy key opens no app on the perimeter and no/mcpconnection. - The deployer rewrites, or refuses. Every app build is tenant-neutral
(
BUILD_SENTINELin@every-app/sdk/cloudflare/sentinel) and the CLI uploads it uninstalled, so the bundle's bindings are placeholders. The deployer builds the upload's bindings from the manifest and the registry row only: the app id, the audience (the registry's Worker name), the gateway's identity key and issuer, the organization andGATEWAY_URLas plain text; theEVERY_APP_GATEWAYservice binding to the gateway'sAppGatewaywith the props{organizationId, appId, workerName}; the D1 databases it created (the CLI's resource ledger records them as the app's, with the CLI's rules: a name recorded for another app, or a database with the name that no deploy of the app created, is refused; a name claimed by a deploy that stopped before recording the id is finished by the next one); its own Durable Object classes (new ones get the nexteveryapp-v<n>migration, a dropped one is refused); its assets; version metadata. Every refusal (the policy below, a dropped class, a migration tag the deployer didn't write) comes before any database is created or migrated: the app's D1 migrations run only once the upload's metadata is built, right before the upload, as in the self-host deploy. A bundle declaring anything else is refused before anything is registered or created: service bindings to any other Worker, dispatch namespaces, another Worker's Durable Objects, KV, R2, Hyperdrive, rate limits, the secrets store, mTLS, Workers AI, email, queues, other secrets or variables, unknown types; crons, routes, domains, tail consumers, logpush, placement or limits of its own; Workflows, helper Workers, dev mode, unlisted compatibility flags, more Durable Object classes than the policy allows (2). The script gets the policy's limits (5,000 ms CPU, 200 subrequests), the tagsorg-<id>,app-<id>andkind-org, and observability on. - Read-back. After the upload the deployer reads the Worker's bindings
back from Cloudflare and compares them with what it sent: the same
names, once each, every field it sent unchanged (the type, ids, props),
and a Durable Object binding's Worker, when listed, the app's own.
Fields Cloudflare adds (a service binding's
environment, a Durable Object binding'snamespace_id) don't count. On any difference it deletes the Worker and fails the job, so a Worker whose bindings the deployer didn't choose never serves. - Registry. Rows are
tier = 'dispatch',scope = 'org', the Workerevery-<orgKey>-<appId>(the only namegetAppFetcherdispatches to), the hostname<appId>-<orgKey>.<THIRD_PARTY_DOMAIN>(none while the domain is unset, or treated as unset for sharing a site with the gateway: nobody can open the app; see "Third-Party Hosts and Per-Host Sessions"). ATHIRD_PARTY_DOMAINthat isAPP_BASE_DOMAIN, or under or above it, refuses every registration and deploy (DEPLOYER_NOT_CONFIGURED). An app keeps its tier: the CLI's self-host deploy refuses a dispatch row and the deploy API a service-binding one (APP_TIER_MISMATCH); a shared app is never a dispatch app. Each deploy is adeploy_jobsrow, one running at a time per app, started before the deploy touches the row (one refused asDEPLOY_IN_PROGRESSchanges nothing);deployed_by_actorisdeploy-key:<token id>(an install from GitHub: the admin's email). - Install from GitHub. An owner or admin (the role read live, as a
deploy key's is; the organization not suspended) can also deploy from
Admin → Apps:
POST /api/admin/apps/installwith a session, a control-plane route behind its CSRF guard, behind the same launch gate on a hosted gateway (APP_INSTALL_NOT_AVAILABLEotherwise); on a self-hosted gateway the same route is the browser install described under "Operator Authority", available once the operator configured its deployer. It also accepts an immutable oss.new release URL (https://oss.new/apps/<64 hex>, no redirects, a 15-second timeout). The gateway downloadshttps://github.com/<owner>/<repo>/releases/latest/download/everyapp-release.tar.gzwith no credentials (https redirects only, 100 MB compressed and again unpacked), reads the tar itself (regular files and directories only: a link, a device, an absolute or..path, a bad header checksum or a file listed twice refuses the archive; nothing butrelease.json,.cloudflareandmigrationsat the top), checks every file againstrelease.json'ssha256and refuses helper Workers in the Build Output. The release is untrusted as an upload is: its bundle meets the same policy, rewrite and read-back, refused before the app is registered. The sha256 proves the archive is whole, not who built it: whoever can publish a release on the repository chooses the code, so an admin installs a repository they trust.deployed_by_actoris the admin's email, and the job records the repository, tag, version and commit it came from (deploy_jobs.source). - The Cloudflare token (
CF_DEPLOY_TOKEN) is a secret of the gateway: the hosted one (Workers for Platforms and D1 edit) and a self-hosted one whose operator turned the browser install on ("Operator Authority"). Whoever reads the gateway's secrets can deploy with it.
Response Contract
- The gateway removes
Domainfrom appSet-Cookieheaders so an app's responses cannot set parent-domain cookies (a page's own script still can, throughdocument.cookie, which is why a second session cookie is refused rather than trusted: see cookie tossing), except on a first-party WebSocket 101 handshake, which passes through untouched (a 101 can't be rewrapped; the third-party path rebuilds it): TODO.md, Build. - HTML responses receive the perimeter CSP floor (
frame-ancestors 'none',base-uri 'self',object-src 'none') without a blanketdefault-src. An app may keepframe-ancestors 'self'(thenX-Frame-OptionsisSAMEORIGIN), since every app has its own origin and that admits neither the gateway nor another app, and may keepbase-uri 'none'; any other value of those three is replaced by the floor. - HSTS,
nosniff, frame denial, and referrer policy are applied uniformly. - Structured gateway events (
logEvent: setup claims, invitations, member changes, app activations, AI usage and upstream failures, proxied requests, unhandled server-function errors) go through a logger that redactscookie,authorization,set-cookieand any token, secret, password or code field. Other error logs (perimeter and app failures) record an error's message and stack as thrown, so errors must not put credentials in their messages. - The CLI's deploys happen outside the gateway, so it logs none of them: who
uploaded an app's live version is on its row (
deployed_by_actor,deployed_at,version_id), the Cloudflare login's email orapi-token:<account id>. A browser install is the gateway's own job:app_install.started, thendeploy_api.deploy_succeededordeploy_api.deploy_failed, and adeploy_jobsrow.
Managing the workspace: the CLI and the MCP tools
Everything the Gateway's admin UI does (users, invitations, roles, groups, who can open an app, caps, usage, the activity trail and the audit log, tokens, AI connections, the organization's name) as everyapp workspace commands for the operator and as everyapp_workspace_* tools an admin's AI client calls on /mcp, who may do what, and what neither surface ever returns.
Threat model
The threat model: assets, actors, trust boundaries, one testable invariant per boundary with the code that enforces it and the test that pins it, what Cloudflare Access adds, what is out of scope, known gaps.