EveryAppEveryApp

Cloudflare Access

How to put Cloudflare Access in front of a self-hosted Gateway (OIDC sign-in or the outer ring), every CLOUDFLARE_ACCESS_* setting, what everyapp access on, off and show change, which paths stay bypassed and why, how MCP clients behave behind Access, and how to get back in.

Cloudflare Access is a way for people to sign in to a self-hosted Gateway: their Cloudflare account ("Sign in with Cloudflare", for the members of the operator's Cloudflare account), Access's one-time PIN (a code emailed to everyone else), the company's identity provider, Access's MFA and device rules. It is optional (the Gateway signs people in by itself) and recommended for a company's Gateway; the person's page is Sign in through Cloudflare Access. Better Auth stays the system of record for users, sessions, organizations, roles, invitations, personal access tokens and the OAuth server MCP clients use; an Access sign-in ends in an ordinary Better Auth session, so everything after it (the perimeter, the identity tokens apps get, app access, /mcp and its OAuth, personal tokens, deactivation) works as after a password. Passwords can stay on or be turned off (EMAIL_PASSWORD_SIGN_IN).

Every Access setting is a Worker secret on the Gateway (everyapp secret set <NAME>; see the secrets page). None can be changed from the admin UI, so a stolen admin session cannot add a way in or change who it lets in (apps/every-app-gateway/src/auth/cloudflare-access/config.ts). A malformed setting turns its feature off and logs access.config_problem (level warn, once per isolate) rather than half-configuring it.

Access sign-in exists only in self_hosted mode. apps/every-app-gateway/src/server/platform-policy.ts sets accessSignIn: true for self_hosted and false for hosted and invalid; where it is false the plugin is not registered, and accessConfigFromEnv() (apps/every-app-gateway/src/auth/cloudflare-access/index.ts) reads an empty configuration and, only when some CLOUDFLARE_ACCESS_* or EMAIL_PASSWORD_SIGN_IN value is set on the Worker anyway, logs one problem: "The Cloudflare Access settings and EMAIL_PASSWORD_SIGN_IN are ignored: this gateway's GATEWAY_DEPLOYMENT_MODE has no Access sign-in." (with none set, nothing is logged). The CLI refuses access on and access off on a hosted Gateway with ACCESS_HOSTED (This Gateway can't be put behind Cloudflare Access / Hosted Gateways ignore the Access settings.); access show answers hosted: true and reads nothing from Access.

The reference install, everyapp.team, runs the outer ring (since 2026-10-23). The full operator runbook is docs/runbooks/cloudflare-access.md, mirrored at /docs/agent/repo/runbooks/cloudflare-access; this page distills it for an agent and points there for the hand-run cf commands and dashboard clicks. The security model's section is /docs/agent/repo/security-model ("Sign-in with Cloudflare Access"). What Access adds to the platform's trust boundaries is in docs/threat-model.md (assets, actors, trust boundaries, one testable invariant per boundary), mirrored at /docs/agent/repo/threat-model.

Two ways, and which to pick

OIDC sign-in Outer ring
What people see A "Continue with Company SSO" button (the label is CLOUDFLARE_ACCESS_OIDC_LABEL) on the sign-in page Access's login before they reach the Gateway at all, then no second login
What Access protects The sign-in Every request to the Gateway's and apps' hostnames, except the bypassed paths
Access app type Access for SaaS (OIDC) Self-hosted, hostname-based (two applications)
MCP clients, the CLI Unaffected Through Access too with Managed OAuth, or through the bypassed paths
Passwords Can stay on, or be turned off Can stay on, or be turned off
Set up with cf zero-trust access applications create and three secret set ("OIDC sign-in setup" below) pnpm everyapp access on (or the cf commands in the runbook)
Settings CLOUDFLARE_ACCESS_OIDC_ISSUER, CLOUDFLARE_ACCESS_OIDC_CLIENT_ID, CLOUDFLARE_ACCESS_OIDC_CLIENT_SECRET, CLOUDFLARE_ACCESS_OIDC_LABEL CLOUDFLARE_ACCESS_TEAM_DOMAIN, CLOUDFLARE_ACCESS_AUD, CLOUDFLARE_ACCESS_APPS_AUD, CLOUDFLARE_ACCESS_OUTER_RING_AUTO_JOIN

The runbook's rule: OIDC sign-in unless a security team requires Access in front of everything; the outer ring suits installs where nothing may be reachable without passing Access first (deny-by-default zones, device posture on every request). Both can be on at once. everyapp access on builds the outer ring, and the reference install runs it.

Costs: every person who signs in through Access uses a Zero Trust seat; the Free plan covers 50 users and blocks the 51st. Bypassed paths and service tokens use no seats. Access's login page is <team>.cloudflareaccess.com; no domain of your own is needed for it.

Settings reference

All ten names are in SETTABLE_GATEWAY_SETTINGS (packages/sdk/src/internal/gatewaySettings.ts), so everyapp secret set <NAME> routes them to the Gateway Worker and each change is a new Worker version, read on its next request. Parsing is readAccessConfig() in apps/every-app-gateway/src/auth/cloudflare-access/config.ts; the "Problem" column is the access.config_problem text, abbreviated; the "Where its value comes from" column follows the dashboard paths in docs/runbooks/cloudflare-access.md.

Name Way Holds Where its value comes from Problem when wrong
CLOUDFLARE_ACCESS_OIDC_ISSUER OIDC https://<team>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client id>. https on <team>.cloudflareaccess.com only; no userinfo, query or hash; the path must end in the client id. The endpoints derive from it: <issuer>/authorization, <issuer>/token, <issuer>/jwks. The SaaS application's Issuer (dashboard: Access controls → Applications → the SaaS application), or built from the team domain and saas_app.client_id of the cf zero-trust access applications create response. CLOUDFLARE_ACCESS_OIDC_ISSUER must be https://<team>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client id>; OIDC sign-in is off. or CLOUDFLARE_ACCESS_OIDC_ISSUER isn't the issuer of CLOUDFLARE_ACCESS_OIDC_CLIENT_ID (it should end in /cdn-cgi/access/sso/oidc/<client id>); OIDC sign-in is off.
CLOUDFLARE_ACCESS_OIDC_CLIENT_ID OIDC The SaaS application's client id. Client ID on the SaaS application; saas_app.client_id in the create response. With any of the three OIDC values missing: OIDC sign-in needs CLOUDFLARE_ACCESS_OIDC_ISSUER, CLOUDFLARE_ACCESS_OIDC_CLIENT_ID and CLOUDFLARE_ACCESS_OIDC_CLIENT_SECRET; it is off.
CLOUDFLARE_ACCESS_OIDC_CLIENT_SECRET OIDC The SaaS application's client secret, used at the token endpoint with PKCE (S256). Client secret, shown only when the application is created (saas_app.client_secret). As above.
CLOUDFLARE_ACCESS_OIDC_LABEL OIDC The button's label, Continue with <label>, on the sign-in page. Trimmed to 40 characters. Default Cloudflare Access. Your choice. None (an empty value is the default).
CLOUDFLARE_ACCESS_TEAM_DOMAIN Outer ring <team>.cloudflareaccess.com (a scheme is allowed; the path must be empty or /). Its origin is the expected iss; keys come from <origin>/cdn-cgi/access/certs. The Zero Trust team's auth_domain (cf zero-trust organization get); the team name chosen when Zero Trust was set up. CLOUDFLARE_ACCESS_TEAM_DOMAIN must be <team>.cloudflareaccess.com; the outer ring is off. Set without CLOUDFLARE_ACCESS_AUD: The outer ring needs both CLOUDFLARE_ACCESS_TEAM_DOMAIN and CLOUDFLARE_ACCESS_AUD; it is off.
CLOUDFLARE_ACCESS_AUD Outer ring The AUD tag(s) of the Access application on the Gateway's own host; comma or space separated; each [A-Za-z0-9._:-]{1,256}. Only assertions for these tags sign people in. Application Audience (AUD) Tag on the application's overview; aud in the API response; applications.gateway.aud in access on --json. CLOUDFLARE_ACCESS_AUD must be the AUD tag of the Access application on the gateway's host (several: comma separated); the outer ring is off.
CLOUDFLARE_ACCESS_APPS_AUD Outer ring The AUD tag(s) of the Access application on *.<app base domain>. Its assertions only vouch for a session on an app host; they never sign anyone in. The apps application's AUD tag; applications.apps.aud in access on --json. With one application covering both hosts, the same tag goes in both settings. CLOUDFLARE_ACCESS_APPS_AUD must be the AUD tag of the Access application on the apps' hosts (several: comma separated); the outer ring is off. Set while the outer ring isn't: CLOUDFLARE_ACCESS_APPS_AUD is set but the outer ring isn't (CLOUDFLARE_ACCESS_TEAM_DOMAIN, CLOUDFLARE_ACCESS_AUD); it does nothing.
CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS Both Email domains whose people join without an invitation: comma or space separated, lowercased, a leading @ dropped, exact match (acme.com admits neither eng.acme.com nor notacme.com). Your choice. An entry that isn't a domain: CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS: "<entry>" isn't an email domain (like example.com); skipped. (the others still count).
CLOUDFLARE_ACCESS_OUTER_RING_AUTO_JOIN Outer ring everyone (case-insensitive): anyone the Access policy admits joins as a member on first sign-in. The Access policy becomes the membership list. Your choice. Any other value: CLOUDFLARE_ACCESS_OUTER_RING_AUTO_JOIN takes only "everyone"; it is off. Set without the outer ring: CLOUDFLARE_ACCESS_OUTER_RING_AUTO_JOIN is set but the outer ring isn't (CLOUDFLARE_ACCESS_TEAM_DOMAIN, CLOUDFLARE_ACCESS_AUD); it does nothing.
CLOUDFLARE_ACCESS_IDP_IDS Both The ids of the Access identity providers whose tokens count (comma or space separated, each [A-Za-z0-9_-]{1,128}; everyapp access show lists them). Set, an assertion or ID token from another provider, or one with no idp.id claim, is refused (access.idp_refused). Unset: any provider the Access policy admits.
EMAIL_PASSWORD_SIGN_IN Both On: true, 1, on, yes or unset. Off: false, 0, off, no. Your choice. Neither: EMAIL_PASSWORD_SIGN_IN is neither on nor off, so password sign-in stays on. Use "false". false with no Access sign-in and no sign-in provider configured: EMAIL_PASSWORD_SIGN_IN=false is ignored: no Cloudflare Access sign-in and no sign-in provider is configured, so turning passwords off would lock everyone out.

EMAIL_PASSWORD_SIGN_IN=false takes effect only while an Access sign-in method or a sign-in provider (Sign-in providers) is configured and some owner has such an identity linked (isPasswordSignInOn() in membership.ts checks the accounts table for a cloudflare-access row, or a row of a sign-in provider that is on, joined to an owner membership), so it cannot lock the owner out. In effect, the Better Auth paths /sign-in/email, /sign-up/email, /request-password-reset, /forget-password, /reset-password and /reset-password/* answer HTTP requests with 403 code PASSWORD_SIGN_IN_DISABLED (Password sign-in is turned off on this gateway. Sign in with <label>.) (apps/every-app-gateway/src/auth/config.ts), admins can't hand out reset links (PASSWORD_SIGN_IN_DISABLED from apps/every-app-gateway/src/server/password-sign-in.ts), and the sign-in page shows only Access (GET /api/auth/sign-in-methods returns emailPassword: false). Server-side calls, the setup claim included, are unaffected. Set it to true to turn passwords back on; it takes effect once the new Worker version is live.

Members who haven't linked Access yet (signIn.pending in access on, linkPending in access show) can't sign in at all once passwords are off: an existing password account whose email isn't verified is linked only through a password sign-in ("Existing accounts" below), and that path is closed. Their Continue with <label> ends on the sign-in page with account_not_linked, worded An account with this email already exists but isn't connected to <label> yet, and password sign-in is off. Ask an admin for help. (apps/every-app-gateway/src/client/sign-in-methods.ts). Have everyone link first, or set it back to true.

Locally, the settings go in .dev.vars; loopback URLs (http://localhost, 127.0.0.1) are accepted only in vite dev builds (import.meta.env.DEV), for the mock Access in apps/every-app-gateway/scripts/access-local.mjs.

OIDC sign-in setup

access on builds the outer ring only; the OIDC way's Access for SaaS application is created by hand (the runbook's "OIDC sign-in" section has the dashboard clicks). The runbook's request body (docs/runbooks/cloudflare-access.md, "OIDC sign-in", where it runs as npx cf@1.0.0-beta.14), with <gateway> and <policy id> filled in:

cf zero-trust access applications create --body '{
  "name": "EveryApp",
  "type": "saas",
  "saas_app": {
    "auth_type": "oidc",
    "redirect_uris": ["https://<gateway>/api/auth/callback/cloudflare-access"],
    "grant_types": ["authorization_code_with_pkce"],
    "scopes": ["openid", "email", "profile"]
  },
  "policies": ["<policy id>"]
}'

The redirect URI is Better Auth's callback for the provider id cloudflare-access, and must match exactly. The Gateway always requests the scopes openid, email and profile with PKCE (SCOPES in apps/every-app-gateway/src/auth/cloudflare-access/oidc-provider.ts; scopes and extra parameters a client asks for are ignored, the request to Access is always the same). policies attaches a reusable allow policy by id (the runbook's example creates one named "EveryApp users"). The response holds saas_app.client_id and saas_app.client_secret (shown only here); the issuer is https://<team>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client id>. Then pnpm everyapp secret set CLOUDFLARE_ACCESS_OIDC_ISSUER, pnpm everyapp secret set CLOUDFLARE_ACCESS_OIDC_CLIENT_ID, pnpm everyapp secret set CLOUDFLARE_ACCESS_OIDC_CLIENT_SECRET, and optionally pnpm everyapp secret set CLOUDFLARE_ACCESS_OIDC_LABEL.

The outer ring

Two Access applications and their AUD tags

Two hostname-based (type: "self_hosted") Access applications, both attached to the same reusable allow policy:

Application Destination AUD tag goes in Its assertions
"Every App gateway" (the name access on uses) the Gateway's host CLOUDFLARE_ACCESS_AUD Sign people in on the Gateway's host, and vouch for sessions anywhere
"Every App apps" *.<app base domain> CLOUDFLARE_ACCESS_APPS_AUD Only vouch for a session on an app host; never a Gateway login

Why two (apps/every-app-gateway/src/auth/cloudflare-access/outer-ring.ts): the Gateway's host is where sign-in happens, so an app host's Access token, should it ever leak out of a browser, must never be a Gateway login. verifyOuterRingAssertion() accepts an assertion whose aud is any configured tag, and sets maySignIn only when one of its aud values is in CLOUDFLARE_ACCESS_AUD. One application covering both hosts works (put its AUD in both settings; the CLI finds it as both and warns ACCESS_ONE_APPLICATION), knowing any app host's Access cookie is then a Gateway login too.

Use hostname destinations, never "Protect this Worker": Worker-level Access answers WebSocket upgrades with 403, which breaks apps that sync over WebSockets. Keep the CF_Authorization cookie HttpOnly (the default) and turn on the binding cookie, so a copied cookie is useless outside the browser it was issued to. An application's session is how long an Access login lasts: access on creates applications with 720h (the most Access allows; --session for another), and the runbook's hand-written bodies use 720h too.

Bypassed paths

Only machine endpoints, which check their own credentials, are let through (Access path overrides with behavior: "public"). The list is GATEWAY_BYPASS_PATHS and MCP_PATHS in packages/cli/src/lib/access/plan.ts and the table in the runbook:

Path On which destination Why it is bypassed
/mcp, /mcp/* the Gateway's host, and an app host only when its manifest sets mcp MCP clients authenticate with an OAuth token or a personal access token, not a browser session; Access would answer them with an HTML login. The wildcard *.<base> gets no override: an app host serves no MCP unless its manifest says mcp: { by, why } (Manifest)
/health the Gateway's host GET (or HEAD) answers 200 {"status":"ok"} after a bounded SELECT 1 against the Gateway's database, else 503 {"status":"error","database":"down"}; for uptime monitors, which can't sign in. It reads no session, names no version and is never cached ("Uptime monitoring" below). Bypassed under Managed OAuth too
/oauth/token, /oauth/register the Gateway's host The OAuth server's token, revocation and dynamic registration endpoints: clients call them from servers
/api/auth/callback/apple the Gateway's host Sign in with Apple returns with a cross-site POST the browser's Access cookie isn't sent on; Better Auth answers it with nothing but a redirect to the same URL as a GET, which is not bypassed, so the sign-in completes behind Access's login. The POST reads no session and creates nothing (/docs/agent/signin)
/.well-known/* the Gateway's host OAuth discovery documents (the authorization-server metadata and the protected-resource metadata, RFC 9728), fetched before any login
Each app's public routes that app's host (its own destination) Whatever the app's everyapp.config.ts declares public (webhooks, a tracking script and its collector) must be reachable by strangers. A health check is not public: a monitor reaches it with a personal access token

Everything else stays behind Access, /oauth/authorize and the consent pages (/oauth/consent*) included: they are pages for people, served to the Gateway's session. The CLI needs no bypass: it deploys, and checks for an owner, by reading and writing the Gateway's D1 database, never through the Gateway. Access path overrides don't cover subpaths (/a is /a only), so the /* forms are used; a destination takes at most 25 overrides. Access matches these paths case-insensitively; the Gateway answers every spelling of a bypassed path that its routes don't serve (/HEALTH, /MCP, /OAUTH/TOKEN, an unclaimed /.well-known/<name>) with a JSON 404, never the control plane (ACCESS_BYPASSED_EXACT in apps/every-app-gateway/src/server.ts).

Uptime monitoring

The Gateway: GET https://<gateway>/health, public and bypassed by Access; 200 {"status":"ok"} when the Gateway reaches its database within two seconds, 503 {"status":"error","database":"down"} otherwise, cache-control: no-store, no session read, no version named; one probe at a time per isolate, its answer reused for five seconds, so a flood costs no D1 reads; other methods answer 405 (apps/every-app-gateway/src/server/health.ts). Point the monitor there; it needs no credential. The Gateway itself probes each app's /__everyapp/health over the service binding when it activates it (probeApp, apps/every-app-gateway/src/server/app-activation.ts), so an app that answers at all is live before its row is.

One app: its routes are private (the starter's /health is a JSON endpoint behind the Gateway, not a public route; a health route needs no public entry), so a monitor sends Authorization: Bearer epat_… with GET https://<app>.<base>/health (or whatever the app serves). What that token opens: everything the app does, as the person it belongs to (apps don't scope-check their own routes, and an mcp:-only token is 403 token_scope on every route of a host without mcp), so make it a Full access token of a dedicated monitoring member who can open that one app and nothing else, bound to that app (Manage workspace → Tokens → Create access token, one app), and note its expiry (90 days by default, a year at most) ends the monitor. Behind the outer ring that request also needs an Access service token (CF-Access-Client-Id and CF-Access-Client-Secret headers) from a Service Auth policy on the "Every App apps" application; make it a reusable policy (an inline policy added from the application's own page makes access on skip that application for good, ACCESS_INLINE_POLICIES). The service token alone opens nothing (the perimeter still demands the personal token), but it removes the outer ring for its holder on every app host and path (Access policies are not path-scoped), and this path is unverified live (the apps' application has the binding cookie on, which Cloudflare says to avoid for non-browser tools). The verified alternative for one monitored app: keep public: [{ path: "/health" }] in that app's manifest, and let the monitor fetch it without a credential.

An app gets a destination of its own on its host when its manifest has public routes or mcp, because Access picks the most specific matching destination: its overrides are /mcp and /mcp/* first, only where the manifest sets mcp (and Managed OAuth is off), then the app's routes in manifest order, each once. A manifest path becomes an Access path_pattern by accessPathPattern(): ** collapses to * (Access's * already spans segments), a :name segment becomes *, and a pattern longer than 512 characters or a segment left with two wildcards (/a*b*) has no equivalent. The Gateway still enforces the route (its globs are narrower and methods count), so an Access override wider than the route costs nothing. What the plan leaves out is a warning on step access.routes (emitPlanWarnings() in packages/cli/src/lib/access/sync.ts, once per run): ACCESS_ROUTE_NOT_EXPRESSIBLE (no Access pattern; the route stays behind Access, people signed in to Access still reach it; data.routes), ACCESS_TOO_MANY_PUBLIC_ROUTES (past the 25th override, two of which are MCP's where the manifest sets mcp; data.routes), ACCESS_APP_OUTSIDE_BASE (an app whose host isn't one label below the app base domain gets no destination; data.apps); ACCESS_FOREIGN_DESTINATION (below) is the fourth on that step. Apps with neither public routes nor mcp need no destination; the wildcard covers them, with no override.

With Managed OAuth on the Gateway's application (oauth_configuration.enabled === true, read by managedOAuthOn()), the Gateway's destination keeps /health alone and the apps' destinations drop the /mcp paths, keeping only each app's public routes: AI apps then use the Gateway's /mcp through Access ("MCP and API clients" below).

How deploys keep public routes in step

syncAppsAccessAfter() (packages/cli/src/lib/access/sync.ts) runs in everyapp deploy after the live wait (packages/cli/src/commands/deploy/deployApp.ts, told whether the manifest has public routes, before the APP_HOST_OUTSIDE_BASE warning) and in everyapp delete after the Gateway is reconciled, before app.delete ends (packages/cli/src/commands/delete/index.ts). It lists the account's Access applications and returns at once when none has a public destination on *.<app base domain> (Access isn't in front). Otherwise, under the registry lease access (LeasePurpose.AccessSync, waiting up to 60 s for an access on or another deploy holding it), it reads every served app (apps rows with status binding, active or disabled and a hostname) and the public[].path list of the manifest each was last deployed from, computes the destinations, and PUTs the apps application only when they differ. Destinations that aren't ours (not the Gateway host, not the wildcard, not a host one label below the base) are kept as found and named in ACCESS_FOREIGN_DESTINATION (data: { application, uris }; access off then won't delete that application).

The result is step app.access with data: { appId, applicationId, changed, removed }. After a deploy: ok Cloudflare Access lets <app>'s public routes through., or skip Cloudflare Access already matched <app>'s public routes.. After everyapp delete (removing: true; packages/cli/src/commands/delete/index.ts reads whether the app had public routes from its manifest before the row is tombstoned): ok Cloudflare Access no longer lets <app>'s public routes through., or skip Cloudflare Access had nothing of <app>'s to take back.. The human output shows the step only when something changed.

Two warnings, never a failed deploy or delete; both are on step app.access and carry data.rerun: "pnpm everyapp access on" (the CLI as it was invoked; see the next section), which the human output renders as Run: pnpm everyapp access on:

  • ACCESS_NOT_CHECKED: the Access applications couldn't be listed, so whether the Gateway is behind Access is unknown and the message says "if". For a login without the Access permissions (the sync catches ACCESS_PERMISSION_MISSING; the warning never carries that code) the message is This Cloudflare login can't read Cloudflare Access, so <app> wasn't checked against it; for any other cf refusal Couldn't check Cloudflare Access for <app> plus the error's first line; each followed by If the Gateway is behind Access, <app>'s public routes need a login there until it is updated. (after a delete: If the Gateway is behind Access, <app>'s host stays listed there until it is updated.). data: { appId, rerun, rerunNote }, rerunNote being with a login that can manage Access, if the Gateway is behind it for the permission case, else if the Gateway is behind Access. The permission case is silent when the app has no public routes (the one thing a sync would change for it); any other refusal is reported regardless.
  • ACCESS_SYNC_FAILED: the apps application was found but couldn't be updated. Couldn't update Cloudflare Access for <app>'s public routes, the error's first line, then Until it is updated, they need an Access login. (after a delete: <app>'s host stays listed there until it is updated.). data: { appId, rerun }.

Deploys never touch the policy: inviting someone still means running access on again.

How update keeps both applications in step

A Gateway release can change what its own host must let through (Rollout 2 of everyapp.team moved /health and took /mcp off app hosts) or which sign-in methods access on vouches for; the plan for both lives in packages/cli/src/lib/access/plan.ts, which ships with the Gateway it describes. So everyapp update (and everyapp setup on a live Gateway, and the deploy that installs one, once it is live) runs keepAccessAfterGatewayDeploy() (packages/cli/src/lib/access/applications.ts) right after the Gateway upload, or after the up-to-date check: it lists the account's Access applications and returns at once when none has a public destination on the Gateway's host or on *.<app base domain> (Access isn't in front; a login without the Access permissions and an account that never opened Zero Trust return the same way, silently, since Access can't be on without them). Otherwise, under the access lease (waiting up to 60 s for an access on or a deploy's sync holding it), it reads the applications, the team's providers, the policy and every served app's public surface again, and runs the same reconcileApplication() access on uses on both applications (one covering both hosts is kept as one, ACCESS_ONE_APPLICATION): our destinations (the Gateway host's bypass list, the wildcard, one destination per app with a public surface), the policy Every App gateway members among the application's reusable policies when it exists, HttpOnly and the binding cookie, the providers on vouches for among its allowed ones, never its session. No difference, no PUT. It creates nothing: a missing application is the warning ACCESS_APPLICATION_MISSING (data.hosts), and the Gateway's three settings are never rewritten here (an application recreated by hand, whose AUD tag changed, is access on's to fix; status and access show say so).

The result is step gateway.access with data: { changed, gateway, apps, combined, missing, appsWithRoutes, appsWithMcp }: ok Cloudflare Access brought in step with this Gateway: <names> updated (<what is let through>). when an application changed, skip Cloudflare Access already matches this Gateway. otherwise; the human output shows only the ok line. The four plan warnings (access.routes) are emitted as after access on. A failure is a warning, never a failed update: ACCESS_NOT_CHECKED (the applications couldn't be listed, said as "if") or ACCESS_RECONCILE_FAILED (they were found but the update failed), each with data.rerun pnpm everyapp access on.

What the Gateway does with the assertion

The Gateway verifies the assertion itself: it serves static assets, so Cloudflare doesn't hand it ctx.access (the runbook, "No ctx.access"), which is why it needs CLOUDFLARE_ACCESS_TEAM_DOMAIN (the expected iss and the JWKS URL) and the AUD tags, rather than reading an identity Cloudflare resolved. Verification is verifyAccessJwt() in apps/every-app-gateway/src/auth/cloudflare-access/jwt.ts; nothing trusts a header on sight:

  • The token is the Cf-Access-Jwt-Assertion header, else the CF_Authorization cookie (browsers send the cookie on every path, bypassed ones included).
  • It must be RS256 with a kid found in the team's JWKS (https://<team>.cloudflareaccess.com/cdn-cgi/access/certs): the key set is cached 10 minutes per isolate, refetched for an unknown kid at most every 30 seconds, and a fetch is given up after 5 seconds. A failed fetch fails closed (access.jwks_fetch_failed, level error): keys are never used past their lifetime, and nobody is signed in by Access until a fetch succeeds.
  • iss must be the team domain's origin, aud one of the configured tags, exp present (60 s clock tolerance on exp, nbf, iat).
  • It must carry a non-empty email (a service token has none: it is a machine, never a user) and not email_verified: false. The email is lowercased. name is kept when present.
  • For the outer ring, type must be "app": Access's organization token (type: "org") is never a vouch for an application.

The Better Auth plugin (apps/every-app-gateway/src/auth/cloudflare-access/plugin.ts) then applies these rules:

  • Sign-in happens only on the Gateway's own host (the host of GATEWAY_URL), on a browser's GET /api/auth/get-session, with an assertion whose maySignIn is true. No session, or an expired one: the person is signed in (access.signed_in, mode: "outer-ring") and gets the session cookie. A session for someone else: it is deleted (access.session_replaced) and the Access person signed in. A session for the same person: the Access identity is linked to that account (access.account_linked; see "Existing accounts").
  • Everywhere else (server-side lookups: the perimeter on app hosts, server functions, the OAuth pages; any other host; non-GET), a session counts only with a verified assertion for the same email on that request; otherwise get-session answers null (access.session_unvouched without an assertion, access.session_mismatch with someone else's). Better Auth's other endpoints over HTTP, except /get-session and /sign-out, have an unvouched session cookie stripped from the request before they run (access.session_ignored), so they answer as if there were none. So someone taken out of the Access policy loses the Gateway in the browser once their Access session ends, whatever Gateway session the browser still holds. Personal access tokens and OAuth tokens don't pass through this.
  • Nothing is made for anyone else. Behind the outer ring an account or session created over HTTP (a password sign-in, OIDC) is refused unless Access vouches for the same email on the request: 403 code ACCESS_REQUIRED ("This gateway signs people in through Cloudflare Access, and this request didn't come through it.") when there is no assertion, ACCESS_EMAIL_MISMATCH ("Cloudflare Access knows you by another email than this account's, so this browser can't be signed in to it here.") when it names someone else. The sign-in page turns the second into "You're signed in to Cloudflare Access as X, but this account is Y, so this browser can't use it here …" using accessEmail from GET /api/auth/sign-in-methods (which also returns emailPassword, the providers list, the sign-up policy and emailConfigured, whether the Gateway sends email), with a Not you? Sign out of Access link to /cdn-cgi/access/logout on every Access refusal.
  • Sign out of the Gateway goes to /cdn-cgi/access/logout when the browser came through the outer ring (apps/every-app-gateway/src/client/components/Sidebar.tsx); otherwise the next page load would sign the browser straight back in.
  • Apps never see Access. The perimeter strips every cf-access-* header (the assertion, Cf-Access-Authenticated-User-Email, service-token credentials) and the cookie header before proxying (packages/perimeter/src/headers.ts); apps get only the Gateway's identity token.

everyapp access on|off|show

everyapp access is on main (PR #379): packages/cli/src/commands/access/command.ts defines the three verbs and their flags, packages/cli/src/commands/access/index.ts runs them, over packages/cli/src/lib/access/plan.ts (the plan, pure), packages/cli/src/lib/access/record.ts (the D1 record), packages/cli/src/lib/access/sync.ts (the sync after a deploy or delete) and packages/cli/src/lib/cf/access.ts (the Zero Trust API through cf); the hand-run cf commands in the runbook do the same job. Step by step for an agent: packages/cli/skills/everyapp/setup/access.md, in an app .claude/skills/everyapp/setup/access.md. It builds the outer ring from the Cloudflare login alone. Nothing is kept on the machine: the Access applications are found again by what they cover (the Gateway's host, *.<app base domain>), the policy by its name Every App gateway members, and the Gateway's settings by their names on the Worker. The one thing on records is what it wrote to the three settings, in the Gateway's D1 (_everyapp_access, one row beside the install row: team_domain, gateway_aud, apps_aud, written_at; packages/cli/src/lib/access/record.ts), since a Worker's secret values can't be read back: a rerun and access show read it to tell whether the settings still name the applications in front of the Gateway, and access off clears it. All three verbs take --gateway, --account-id, --json and --verbose. Every call to Access needs a Cloudflare login with Access: Apps and Policies (edit) and Access: Organizations, Identity Providers, and Groups (edit); without them cf's refusal becomes ACCESS_PERMISSION_MISSING (This Cloudflare login can't manage Access, explainAccessError() in packages/cli/src/lib/cf/access.ts) with that fix and data.url https://dash.cloudflare.com/profile/api-tokens. Every command the verbs suggest (data.rerun, fixes, next[].command) starts with the invocation that ran the CLI (CLI in packages/cli/src/lib/output/command.ts): pnpm everyapp when pnpm started it, as the docs run it, and this page quotes that form.

In the --json stream (the JSON protocol) access on runs as step access.update (start Checking Cloudflare Access..., a progress event per create or update: the team, the global session, Sign in with Cloudflare, the one-time PIN, the policy, its rules, each application, then ok), access off as access.off (Turning Cloudflare Access off...), and the wait for the registry lease as gateway.lease; the four plan warnings are on access.routes, and the command's own (ACCESS_EMAIL_NOT_INVITED, ACCESS_IDP_UNVERIFIED_EMAILS, ACCESS_IDP_ANY_CLOUDFLARE_ACCOUNT, ACCESS_LOGIN_NOT_MEMBER, ACCESS_ONE_APPLICATION, ACCESS_INLINE_POLICIES) on access.update. setup runs the same code once the owner exists, by default: offered at a terminal ("Put Cloudflare Access in front of your Gateway?", Yes by default), without a question under --json or --yes when the account can (a Zero Trust team exists, the Cloudflare login is an owner or admin of the Gateway), else the warning ACCESS_SETUP_SKIPPED names access on; --no-access leaves it off (Setup): its events appear inside setup's stream and its done becomes the step setup.access ok, with access on's message and data. The secrets.gateway step applyGatewaySettings() emits gets no line of its own in the human output while access.update or access.off is running (packages/cli/src/lib/output/events.ts): the settings are one line of the command's own summary.

access on

pnpm everyapp access on turns Access on in front of the Gateway and its apps, or brings it up to date. Idempotent: run it again any time (after inviting someone, so Access lets them in; after a change by hand); it changes only what differs and reports what was already so, never removes anyone from the policy, never takes a sign-in method off an application, never touches a destination it didn't make, and leaves the team's global session alone unless asked.

Flag What it does
--email <address> Also let this address past Access (repeatable). An account still takes an invitation: an address with no account or invitation gets the warning ACCESS_EMAIL_NOT_INVITED (data.emails) and the run goes on. Not an address: ACCESS_EMAIL_INVALID.
--email-domain <domain> Also let everyone at this email domain past Access (repeatable; a leading @ is dropped). Does not set CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS; the done output suggests it. Not a domain: ACCESS_DOMAIN_INVALID.
--team <name> The Zero Trust team to create when the account has none; the sign-in page becomes <name>.cloudflareaccess.com for good. Lowercase letters, digits and dashes (ACCESS_TEAM_INVALID). Ignored once a team exists.
--session <duration> How long an Access login lasts on the applications on creates, like 720h or 24h (default 720h). An existing application keeps its own unless this is given. Not a Go-style duration: ACCESS_SESSION_INVALID.
--team-session <duration> Also set the team's global session (how often people sign in at the identity provider). It applies to every Access application in the account; without the flag it is never touched (a new team gets this, else --session).
--write-settings Write the three Gateway settings even when they are set already and the D1 record names these applications (reason: "asked").

What it does, in this order (turnAccessOn() in packages/cli/src/commands/access/index.ts; the people and what Access has are read before the first write, the applications and the apps' public routes later, under the lease):

  1. Reads who the Gateway knows (readPeople(): every account that isn't deactivated, that is every users row not banned or whose ban has expired, with no join to an organization membership; every pending unexpired invitation's email; and the accounts with a credential account and no cloudflare-access account yet, the ones still to link) and what Access has (the organization, and its policies when there is one; an account that never opened Zero Trust refuses every Access call but the team's creation, so nothing else is read before the team exists). With nobody to let in and no policy yet it refuses with ACCESS_NOBODY_ALLOWED rather than put the setup link behind an Access nobody passes (rerun: pnpm everyapp access on --email <your address>).
  2. The Zero Trust team. None in the account (zero-trust organization get answers 404, or access_not_enabled on an account that never opened Zero Trust: both mean no team, and organization create works on either, checked live on 2026-10-24): at a terminal it asks for the name (the zone's first label is suggested); unattended (--json, no TTY, or CI set) without --team it fails with ACCESS_TEAM_REQUIRED (data.suggested). A name Cloudflare refuses (they are shared across Cloudflare) is ACCESS_TEAM_CREATE_FAILED. The team domain is permanent once used. No dashboard step, plan pick or payment details are needed for the API path.
  3. The two ways to sign in, read once the team exists. Sign in with Cloudflare (type cloudflare, named Cloudflare, config.restrict_to_account_members: true): the person's own Cloudflare account, with Cloudflare's MFA, for the members of the operator's Cloudflare account; Cloudflare adds it itself to a team made in its dashboard ("Cloudflare is now the default identity provider for all newly created Zero Trust accounts", its changelog of 2026-05-19), but a team made through the API has none (checked live 2026-10-24), so on creates it when the team lacks one (createCloudflareIdentityProvider(), done.data.identityProviders.cloudflareAdded). The one-time PIN (onetimepin): a code emailed to everyone else; created when the team lacks one (oneTimePinAdded). Those two are what the applications allow (allowedSignInProviders() in plan.ts: the one-time PIN and a cloudflare provider restricted to the account's members, the ones on vouches for; the Zero Trust team is account-wide, so a provider on it says nothing about who should sign in here). Every provider of the team is listed in done.data.signInMethods, each with allowed; the ones on doesn't vouch for (Okta, a generic oidc one, an unrestricted cloudflare one) are named in a note of the done message and left for the operator to allow on the applications in the dashboard; on never takes a provider off an application that allows it. The person picks on Access's chooser page, which on turns on by allowing more than one provider. Who signs in with what: a member of the operator's Cloudflare account can use Sign in with Cloudflare, which asserts that account's email, so it only works when that email is a member or invitee of the Gateway (the warning ACCESS_LOGIN_NOT_MEMBER says when the Cloudflare login's own address isn't: use the emailed code, or invite that address); everyone else types their address and gets the code. A generic oidc or saml provider is left off with the warning ACCESS_IDP_UNVERIFIED_EMAILS: the Gateway signs people in as the email Access asserts, and such a provider may let people choose their own. A cloudflare provider with "Restrict to account members" off is left off the applications and gets ACCESS_IDP_ANY_CLOUDFLARE_ACCOUNT (on on and show): any Cloudflare user could then sign in there, and Cloudflare's docs don't say the address it asserts is verified, so someone who registers a Cloudflare account under a member's address could sign in as that member; turn it on (Zero Trust → Settings → Authentication → the provider) and run on again (the PIN alone signs people in until then; an application that allowed the provider before keeps it). The provider's restrict_to_account_members is read as it is, never changed.
  4. The policy Every App gateway members (reusable, decision: allow, found by name) gains an email rule per member and invitee and per --email, and an email_domain rule per --email-domain; rules are merged, never removed (taking someone off is deactivation in EveryApp, or the dashboard).
  5. The two applications, under the registry lease access (LeasePurpose.AccessSync, announced on step gateway.lease; on waits up to 5 minutes for a deploy's sync holding it, and a sync waits for on): the account's Access applications and every served app's public surface (readPublicSurfaces(): its public routes, and whether its stored manifest sets mcp) are read here, under the lease, so what a deploy finishing at the same time wrote is seen; then the apps' application first, so its AUD exists before the Gateway's settings name it. A new application is created with type: "self_hosted", its destinations and overrides (above), the policy, session_duration, http_only_cookie_attribute: true, enable_binding_cookie: true, allowed_idps = every provider of the team (Sign in with Cloudflare and the PIN included), auto_redirect_to_identity only when that is one provider (with two, Access shows its chooser), and app_launcher_visible true for the Gateway's, false for the apps'. An existing one is updated only in the fields on owns (our destinations, with foreign ones kept; the policy appended to its reusable policies; the two cookie settings, the binding cookie left as found where Managed OAuth is on; its allowed_idps with the team's providers added to what it allowed already, nothing taken off, and auto_redirect_to_identity recomputed from the result, so an application made before Sign in with Cloudflare gains it on the next on (done.data.applications.<which>.signInAdded, the ids added; the human line says sign-in methods added); the session only with --session), and only when something differs. One with inline (non-reusable) policies is left alone with ACCESS_INLINE_POLICIES. One application covering both hosts is kept as one (ACCESS_ONE_APPLICATION).
  6. The Gateway's settings CLOUDFLARE_ACCESS_TEAM_DOMAIN, CLOUDFLARE_ACCESS_APPS_AUD and CLOUDFLARE_ACCESS_AUD, all three in one new Worker version through the same lease (gateway.secrets), replaceable-version check and cf workers secrets bulk as secret set (applyGatewaySettings() in packages/cli/src/commands/secret/index.ts), so "set AUD last" doesn't arise. Values can't be read back, so what was written is recorded in the Gateway's D1 (_everyapp_access, writeAccessSettingsRecord()), and they are written again when one of the three names is missing on the Worker (reason: "missing"), when an application was created ("created"), when nothing is recorded yet ("unrecorded": the first run after the record existed), when the record doesn't name the team domain and both AUD tags of the applications found ("changed", settingsNameApplications(): an application recreated by hand has a new AUD tag, and the old one locks everyone out while the settings look set), or with --write-settings ("asked"); otherwise outcome: "kept" (Gateway settings kept: they name these applications already (--write-settings writes them again).). The done line adds an application's AUD tag had changed for changed and recorded, so a rerun can tell when they go stale for unrecorded.

The done event's data:

Field Holds
team { domain, created, sessionSet, globalSession }
signInMethods [{ id, type, name, allowed, restrictToAccountMembers? }], every provider of the team; allowed whether the applications allow it (the PIN and a restricted cloudflare provider: true; anything else: false); restrictToAccountMembers only on a cloudflare provider (true when only the account's members may use it)
identityProviders { oneTimePinAdded, cloudflareAdded }: which of the two providers this run created
policy { id, name, created, added, emails, domains } (added: the emails and @domains this run added)
applications { gateway, apps, combined }; each application { id, aud, name, outcome, destinations: [{ uri, public: [path patterns] }], allowedIdps: [provider ids], signInAdded: [provider ids this run allowed on it] }, outcome one of created, updated, unchanged, skipped
settings { names: [the three], outcome: "set" | "kept", reason: "missing" | "created" | "unrecorded" | "changed" | "asked" | null }
signIn { url: "https://<gateway host>", pending: [emails], first: "pin" | "idp" | "both" }: pending are the accounts with a password and no Access identity, who sign in once with their password through Access; first says what Access shows first: the emailed code (pin, the PIN alone), one identity provider (idp), or its chooser between Sign in with Cloudflare, the PIN and any other provider (both, the usual case)
next [{ command, note }]: pnpm everyapp secret set CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS first, when --email-domain was given, then pnpm everyapp secret set EMAIL_PASSWORD_SIGN_IN (note false, once everyone has linked Access)

An agent tells the person who is in signIn.pending. The human-readable output ends with the sign-in URL and the same note.

access off

pnpm everyapp access off turns Access off. It asks first (Turn Cloudflare Access off for <host>?); unattended it fails with CONFIRMATION_REQUIRED unless --yes (-y) is given, and a "no" is CANCELLED ("Cancelled. Access stays on."). The order is the reverse of on: first the three settings are removed from the Gateway in one Worker version (so passwords sign people in again at once, and the Gateway stops requiring an assertion), the _everyapp_access record is deleted (clearAccessSettingsRecord()), then the two applications are deleted. An application that also covers something else keeps only the destinations that aren't EveryApp's (it is "trimmed", not deleted; the question says Takes <host> and *.<base> out of <name>). --keep-applications leaves both applications: Access still asks people to sign in there, and the Gateway no longer requires it. The team, its identity providers and the policy stay for other applications to use. When an application to be deleted has Managed OAuth on, the question adds that AI apps connected through it stop. Nothing held on the Worker and nothing to delete: no question, Cloudflare Access isn't in front of <host>. Nothing changed., data.removed: false. Otherwise data is { removed: true, settings: [names removed], applications: { deleted: [ids], trimmed: [ids], kept } }.

access show

pnpm everyapp access show changes nothing. It reports: whether Access is on (all three settings present on the Worker and an application on the Gateway's host found), the team and its global session, the sign-in methods (Sign in with a Cloudflare account or a one-time PIN; access on adds … when the team lacks one of the two; open to any Cloudflare account with the warning ACCESS_IDP_ANY_CLOUDFLARE_ACCOUNT when a cloudflare provider isn't restricted to the account's members; on the team, not allowed here: … for the team's other providers), who the policy lets in and who isn't in it yet (accounts and invitees without an email rule in the policy, a domain rule not counting: notYet), each application (name, whether its paths match what on would make, whether it allows the providers on vouches for as on would leave it (sign-in methods differ otherwise; an application naming no provider, which Access reads as every provider of the team, differs too, since on always writes the explicit list), whether the policy is attached, Managed OAuth on the Gateway's), which Access settings the Worker has and whether they are current, and who still has to link (linkPending). Whether the settings (unreadable) still name the applications in front is known once on has recorded them: settings.current is true when the _everyapp_access record names the team domain and the AUD tags of the applications found (the "Settings" line says name the applications above), false when it doesn't (name an application that no longer exists: nobody can sign in; access on rewrites them), null when Access isn't on, nothing is recorded, or no apps application is found ((values can't be read back)). fixes holds pnpm everyapp access on when Access isn't on, settings.current is false, an application's paths differ, the team lacks Sign in with Cloudflare or the PIN, an application's sign-in methods differ, the policy isn't attached to one, or someone is missing from the policy. The human output's Access line is on, off, applications exist, Gateway settings missing (an application on the Gateway's host, not all three settings) or not available on a hosted Gateway; the done message is Cloudflare Access is on for <host>. or … is off for <host>..

data: { on, hosted, team: { domain, sessionDuration } | null, policy: { id, name, emails, domains, notYet } | null, signIn: { methods: [{ id, type, name, allowed, restrictToAccountMembers? }], cloudflare, pin, openToAnyCloudflareAccount, pinned, pinCommand: [provider ids] } | null, applications: { gateway: { id, aud, name, outcome: "unchanged", destinations, allowedIdps, signInAdded: [], inSync, signInInSync, managedOAuth, policyAttached } | null, apps: { id, aud, name, outcome: "unchanged", destinations, allowedIdps, signInAdded: [], inSync, signInInSync, policyAttached } | null, combined }, desired: { apps: [{ uri, public: [path patterns] }] }, settings: { names: [...], current: true | false | null }, linkPending: [emails], fixes?: ["pnpm everyapp access on"] } (signIn.cloudflare and signIn.pin: whether the team has each; signInInSync: the application allows the providers on vouches for (methods[].allowed) and redirects past the chooser only with one). On a hosted Gateway it says Access isn't available there (hosted: true, nothing read from Access).

pnpm everyapp status says whether Access is on, from the Gateway Worker's secret names and the D1 record (accessStateOf() in packages/cli/src/lib/access/settings.ts; on when all three settings are on the Worker; teamDomain from the record, else null), and, when it is on, whether it is in step with this Gateway: it lists the Access applications, the team's providers and the organization, reads every served app's public surface, and computes the same drift access show and the reconcile after update compute (accessDrift() in packages/cli/src/lib/access/plan.ts): data.access is { on, teamDomain, drift: { paths, signIn, missing, settings } | null, driftUnread: { permission, error } | null } (paths: hosts whose application lets other paths through than the plan; signIn: hosts whose application doesn't allow the sign-in methods on vouches for; missing: hosts with no application; settings: the D1 record names other applications than the ones found, an application recreated by hand), or null on a hosted Gateway, before the first Worker upload, or when cf couldn't list the Worker's settings. The Access row reads on · in step with this Gateway · everyone signs in through Cloudflare Access first, on · drift: paths differ on <hosts>; sign-in methods differ on <hosts>; no application in front of <hosts>; the settings name other applications (then the problem ACCESS_DRIFT, a warning, with run pnpm everyapp access on), on · in step? this login can't read Access (driftUnread.permission: true, no problem) or on · in step? couldn't check (the problem ACCESS_DRIFT_UNKNOWN), or off (what to do about it is data.next's ACCESS_OFF, pnpm everyapp access on, under status --json); when the Worker's secrets couldn't be listed it reads ! couldn't check and the problem ACCESS_UNKNOWN (a warning: Couldn't check whether Cloudflare Access is on / Cloudflare said: <error>.) is one of data.fixes ({ code: "ACCESS_UNKNOWN", severity: "warning", message, run: null }; packages/cli/src/commands/status/problems.ts, render.ts). access show reports the same drift as data.drift beside its per-application inSync and signInInSync, and lists pnpm everyapp access on in fixes.

The ACCESS_IN_FRONT warning

ACCESS_IN_FRONT is raised by two commands, when the CLI's own HTTPS probe finds Access answering before the Gateway. probeHttps() (packages/cli/src/lib/domains/https.ts) makes one GET without following redirects and sets access: true for a 3xx whose Location matches cloudflareaccess.com or /cdn-cgi/access/.

  • everyapp setup, step setup.owner (packages/cli/src/commands/setup/runSetup.ts): Cloudflare Access protects <host> / Whoever opens the setup link signs in to Access first. Setup still waits for the claim.
  • everyapp deploy, step app.live (packages/cli/src/commands/deploy/steps/waitForLive.ts), only when the wait runs out: in the normal case a deploy behind Access ends live with no warning. The probe's Access answer makes the step stop sending requests and only read the app's row, while the Gateway's per-minute cron probes every binding app over its service binding and makes it active (activateDeployingApps in apps/every-app-gateway/src/server/app-activation.ts; the Gateway's home page and Admin → Apps do the same for each binding app they list). The progress line meanwhile is Cloudflare Access answers before the Gateway; waiting for the Gateway to make <app> live (it looks every minute). After 10 minutes still binding: Cloudflare Access answers before the Gateway, and the Gateway hasn't made <app> live after 10m 00s / The deploy stalled: the Gateway should have reached <app>'s Worker within a minute of the upload. <app> stays uploaded and bound; opening https://<hostname> signed in through Access finishes it when the Gateway can reach it., data: { hostname, rerun: "pnpm everyapp status", rerunNote: "then deploy again" }; the outcome is first_visit. The same stall as APP_NOT_LIVE without Access, as a warning, since this run can't tell the Gateway's reasons apart from behind Access: run status (the Gateway's version, data.mode, whether the app is bound), then deploy again.

Neither is an error. ACCESS_IN_FRONT is a warning event in the --json stream (the JSON protocol).

Membership rules

Access only says who someone is (a verified email). Whether they get in is the Gateway's decision, applied by Better Auth database hooks in apps/every-app-gateway/src/auth/cloudflare-access/membership.ts for both ways in. Access's policy decides who reaches the Gateway; the Gateway decides who has an account:

Who Result
A member who isn't deactivated Signs in.
A deactivated member Refused, ACCESS_ACCOUNT_DEACTIVATED ("This account has been deactivated."), whatever Access says.
Someone with a pending, unexpired invitation for that email Joins: every such invitation is accepted (status flipped first, so two sign-ins at once can't both use it), with the invited role (default member) and the organization's "Available to everyone" apps; invitation.accepted is logged with via: "cloudflare_access". No invite link or password needed.
Someone whose email domain is in CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS Joins the first owner's organization as member with the "Available to everyone" apps, as if invited (access.member_joined, via: "auto_join_domain"). An admin grants other apps.
Anyone the Access policy admits, through the outer ring only, with CLOUDFLARE_ACCESS_OUTER_RING_AUTO_JOIN=everyone Joins the same way (via: "outer_ring_everyone"). The OIDC path never uses this setting.
Everyone else Refused, ACCESS_NOT_INVITED ("You don't have an account on this gateway. Ask an admin to invite you."); nothing is created for them (the refusal happens before the user row).
Anyone, before the Gateway has been claimed with its setup link Refused, ACCESS_NOT_SET_UP ("This gateway hasn't been set up yet: its owner signs in first.").

After an OIDC sign-in the code comes back to the sign-in page as ?error=<code> and is explained there (accessSignInErrorMessage() in apps/every-app-gateway/src/client/sign-in-methods.ts; <label> is the button's label); a refused outer-ring sign-in logs access.sign_in_refused with the code as reason and makes no session: get-session answers null, and the cookie of a session it had just replaced is deleted (plugin.ts). Invitations work as before (Invite people); an invited person may also just sign in with Access using the invited email.

?error= The sign-in page says
ACCESS_NOT_INVITED You don't have an account on this gateway yet. Ask an admin to invite you.
ACCESS_NOT_SET_UP This gateway hasn't been set up yet: its owner has to claim it first.
ACCESS_ACCOUNT_DEACTIVATED, BANNED_USER This account has been deactivated. Ask an admin to reactivate it.
account_not_linked Passwords on: An account with this email already exists. Sign in with its password once to connect <label>; after that, <label> signs you in. (the page then connects Access after the password sign-in). Passwords off: An account with this email already exists but isn't connected to <label> yet, and password sign-in is off. Ask an admin for help.
email_does_not_match That <label> identity has a different email than this account, so it wasn't connected. (the account menu's Connect path)
PASSWORD_SIGN_IN_DISABLED Password sign-in is turned off on this gateway. Continue with <label>.
ACCESS_EMAIL_MISMATCH With accessEmail: You're signed in to Cloudflare Access as <email>, but you signed in with <label> as someone else. This browser can only be signed in as <email> here.; without: Cloudflare Access knows you by another email than the account you signed in with, so this browser can't use it here.
ACCESS_REQUIRED This gateway signs people in through Cloudflare Access, and this page didn't come through it. Open the gateway at its own address.
anything else Signing in with <label> didn't finish. Please try again.

An Access identity is linked to an existing account with the same email only when that account's own email is verified (Better Auth 1.7's rule for trusted providers, requireLocalEmailVerified; the provider cloudflare-access is in trustedProviders, apps/every-app-gateway/src/auth/config.ts). Accounts created with a password, the owner's from the setup link included, aren't verified: someone who registered with another person's address must never get that person's account. Each such account connects once, while signed in:

  • Outer ring: sign in with the password once, through Access. The Gateway sees the same email on both sides, links them (linkSignedInAccount(), access.account_linked) and marks the email verified. From then on Access alone signs them in. access on names who still has to do this (signIn.pending), and access show lists them under linkPending.
  • OIDC: the Continue with <label> button answers account_not_linked ("An account with this email already exists. Sign in with its password once to connect …"); the sign-in page then connects Access after the password sign-in. Or, signed in, the Connect <label> item in the account menu (authClient.linkSocial).

The Access account is keyed by the verified email, not sub: Access changes sub when someone is removed and added back (oidc-provider.ts). New people who join through Access never get a password.

Behind the outer ring, a browser can only be signed in as the person Access says it is. An account whose email isn't the one Access asserts can't be used there at all, the owner's included (ACCESS_EMAIL_MISMATCH). Claim the Gateway, and invite people, with the addresses the identity provider (or the one-time PIN) gives Access; Sign in with Cloudflare asserts the person's Cloudflare account email, so an operator whose Gateway account has another address uses the emailed code, or invites the Cloudflare one (ACCESS_LOGIN_NOT_MEMBER says so at access on, and setup's Access question names both addresses when they differ). Enable only identity providers that verify email addresses (one-time PIN, Sign in with Cloudflare restricted to the account's members, Google Workspace, Okta, Microsoft Entra ID); a generic OIDC or SAML provider that lets users choose their own email would let them choose someone else's, and a Cloudflare provider open to any Cloudflare account relies on Cloudflare asserting only verified addresses, which its docs don't promise (ACCESS_IDP_ANY_CLOUDFLARE_ACCOUNT).

Deactivate, not Delete

Deactivate in Manage workspace → Users → ⋯ → Deactivate: that signs the person out everywhere at once and revokes their personal access tokens and AI app connections, whatever Access says. Taking them out of the Access policy alone stops new Access sign-ins and, behind the outer ring, ends their browser's access when their Access session runs out (the application's session duration), but personal access tokens and OAuth connections never pass through Access and keep working; the exception is an AI app connected through Access's Managed OAuth, which stops within the access token lifetime. Revoking their Access session in Zero Trust → My Team → Users stops the browser sooner. Deploying takes the Cloudflare login, not a Gateway account: revoke that in Cloudflare.

With auto-join (CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS, or the outer ring's everyone), Delete wouldn't keep anyone out: their next Access sign-in would make a new account and join again. accessWouldLetBackIn() (apps/every-app-gateway/src/server/access-rejoin.ts, over joinsWithoutInvitation() in membership.ts) makes Delete user deactivate instead for such a person when they have no other organization, with the reason "Deleted by …; deactivated instead, as Cloudflare Access would let them back in" (AdminService.ts, member.deactivated with reason: "deleted_with_access_auto_join"), so they stay listed and can be reactivated.

MCP and API clients behind Access

Without Managed OAuth (the bypass paths)

With the bypasses above, MCP clients are unaffected: the Gateway's /mcp takes an OAuth token the Gateway issued or a personal access token (epat_…), /oauth/token, /oauth/register and /.well-known/* are reachable, and an app host whose manifest sets mcp takes a personal access token at its /mcp (other app hosts serve no MCP, and nothing is bypassed there). Connecting an AI app still shows Access's login in the browser at /oauth/authorize, because that page is for people: the person signs in to Access, then to the Gateway's consent page. Personal access tokens work everywhere they did (MCP). The CLI never calls the Gateway (it reads and writes D1 through cf), so nothing is bypassed for it.

With Managed OAuth

Access's Managed OAuth (a beta) is a switch on an application that makes Access itself the OAuth server AI apps talk to. With it on for the Gateway's application, nothing on the Gateway's host needs a bypass: Claude, ChatGPT, Claude Code and the rest log in at Access like a browser does, and the Access policy decides who may use them. An AI app must support RFC 8707 resource indicators (the current MCP specification requires it of clients); an older one is refused at Access's authorize step.

What changes (docs/runbooks/cloudflare-access.md, "MCP through Access too"; apps/every-app-gateway/src/server/mcp-auth/routes.ts, access-caller.ts, apps/every-app-gateway/src/server/toolbus/gateway-mcp.ts):

  • A request to /mcp without a token gets 401 invalid_token from Access, with a WWW-Authenticate header pointing at Access's own discovery documents, which Access serves on your hostname (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/mcp; issuer https://<team>.cloudflareaccess.com, with PKCE, dynamic client registration and refresh tokens). The Gateway's own OAuth server, consent page and registration go idle.
  • Every /mcp request Access lets through carries a Cf-Access-Jwt-Assertion. serveMcpThroughAccess() runs only when the outer ring is configured and the header is present (the header only, never the CF_Authorization cookie: on a bypassed /mcp the cookie would reach the Gateway unverified and a cross-site POST could act as the person). It first applies the browser Origin rule (browserOriginAllowed(): no Origin, the Gateway's own, or one in MCP_ALLOWED_ORIGINS; otherwise 403 JSON-RPC error -32000 "Forbidden: origin not allowed.") before anything is verified or read for the person, because behind Managed OAuth Access vouches for a cookie-carrying browser request too. Then it verifies the assertion; one that doesn't verify, or is the apps' application's, is logged mcp.access_assertion_ignored (reason: "invalid" or "audience") and the request falls through to the usual bearer check (the Authorization header, which holds Access's own opaque token, is then refused like any unknown bearer).
  • A verified assertion resolves the caller (resolveAccessCaller()): the accounts row { providerId: "cloudflare-access", accountId: <email> } the browser sign-in linked, never a users row that merely has the same email. No such row: 403 { error: "forbidden", reason: "not_connected", message: "No EveryApp account is connected to this Cloudflare Access identity. Open <GATEWAY_URL> in your browser, sign in once, then connect again." } (mcp.access_caller_refused). /mcp never creates an account or a membership, whatever the auto-join settings say. Deactivated: account_deactivated. The organization is the member's only one; with several, the active organization of their most recent live browser session; otherwise organization_unresolved ("You belong to several organizations here. Open the gateway in your browser and pick one first."). A suspended organization: organization_suspended.
  • The caller then acts like a browser session: credential kind access with the email as id, write access, every app the member can use (not a connection's chosen apps or a token's one app). Apps see the actor access:<email>; the mcp_tool_calls audit row records kind access and the email, the mcp.tool_call log line the user id only. Nothing is cached: every request, and every poll of a held subscriptions/listen stream, resolves the person again, so deactivation, removal or suspension takes effect on the next one.
  • No connection row is written, so AI connections (/settings/connections) shows nothing for it. Revoke at Access (the revocation endpoint its discovery document lists), take the person out of the policy (the AI app stops within the access token lifetime, 15 minutes in the runbook's body), or deactivate.
  • An AI app connected before the switch gets Access's 401 on its next request and connects again, through Access; its old connection under AI connections is dead weight.

Turning it on is by hand (everyapp access on does not offer it; an option for it is an open TODO). First finish the outer ring (team domain and both AUD tags): without it the Gateway never looks at the header and an AI app loops on 401. Then give the Gateway's Access application an oauth_configuration (the API replaces the application on PUT, so send what GET returned with no overrides): enabled: true, dynamic_client_registration: { enabled: true, allow_any_on_localhost: true, allow_any_on_loopback: true, allowed_uris: ["https://claude.ai/api/mcp/auth_callback", "https://chatgpt.com/connector_platform_oauth_redirect"] }, grant: { session_duration: "168h", access_token_lifetime: "15m" }. Claude Code and Codex register with a redirect on localhost, which the two allow_any_* settings cover; claude.ai and ChatGPT need their callback URLs in allowed_uris (https only; a path may end in /*). grant.session_duration is how long an AI app stays connected before the person logs in at Access again; access_token_lifetime how often Access re-checks the policy meanwhile. In the dashboard: the application → Advanced settings → Managed OAuth. Once access on (or a deploy's sync) sees oauth_configuration.enabled on the Gateway's application it drops the /mcp overrides on both applications; <app>.<gateway>/mcp is then reachable by browsers only (the perimeter doesn't take the assertion on app hosts yet), and AI apps use the Gateway's /mcp. Don't turn Managed OAuth on for the apps' application. The binding cookie hasn't been tried with Managed OAuth: the runbook's body leaves it off on the Gateway's application, and access on leaves it as found there.

Going back: set oauth_configuration.enabled to false and restore the overrides (access on does that once it no longer sees Managed OAuth). Nothing changes in the Gateway: it takes the assertion on /mcp only when a request carries one. AI apps that connected through Access get the Gateway's 401 next and connect again through the Gateway's own OAuth server.

Personal access tokens and service tokens

Behind Managed OAuth there is no bypass left, so a script's Authorization: Bearer epat_… alone gets Access's 401. The runbook's answer: add a Service Auth policy to the Gateway's application and send the service token's CF-Access-Client-Id and CF-Access-Client-Secret headers alongside the personal token. A service token's assertion names no person, so verifyAccessJwt() refuses it, the Gateway logs mcp.access_assertion_ignored with reason: "invalid" (it can't tell a service token's assertion from a forged header) and checks the personal token as usual. This has not been checked against a live Managed OAuth application. Service tokens use no seats. A request carrying a person's own Access token instead (cloudflared access curl) acts as that person with every app they can use; the personal token alongside is ignored, its app and scope with it.

What claude.ai and Claude Code need

Without Managed OAuth: nothing beyond the bypasses access on makes; they connect through the Gateway's OAuth server as on any Gateway (MCP), and the person passes Access's login in the browser at /oauth/authorize. With Managed OAuth: the person must have opened the Gateway in a browser and signed in once through Access (so the Access identity is linked), the client must support resource indicators, Claude Code's localhost redirect is covered by allow_any_on_localhost/allow_any_on_loopback, and claude.ai's callback https://claude.ai/api/mcp/auth_callback must be in allowed_uris. The AI app loops on 401 invalid_token when the WWW-Authenticate header names cloudflare-access-protected-resource and the person isn't allowed by the policy or identity provider; when it names oauth-protected-resource (or realm="every-app" with MCP_OAUTH_ENABLED off) the Gateway didn't take the assertion, and the logs say why: mcp.access_assertion_ignored invalid (wrong CLOUDFLARE_ACCESS_AUD or team domain, or access.jwks_fetch_failed alongside), audience (the apps' application is in front of the Gateway's host), or no such line (the outer ring isn't configured, or the Gateway isn't self_hosted). The person's steps for connecting an AI client are Use your apps from Claude or ChatGPT.

Recovery

All Access settings are Worker secrets, so getting back in is removing or changing one; each is a new Worker version, live at once.

  • Locked out behind the outer ring (a wrong AUD tag, Access misconfigured, the owner's email isn't the one Access asserts): removing CLOUDFLARE_ACCESS_AUD turns the outer ring off at once; Access still guards the hostnames, and passwords work as without it. pnpm everyapp access off --keep-applications removes all three settings in one version; pnpm everyapp secret unset CLOUDFLARE_ACCESS_AUD deletes just that one (one cf workers secrets bulk call makes a new version of the live Worker without the name, after checking the Worker serves a version everyapp recorded; no deploy, no checkout read; it asks first unless --yes). Without a checkout, cf workers secrets delete CLOUDFLARE_ACCESS_AUD --worker every-app-gateway uploads a new version without it; run pnpm everyapp setup from a terminal later (it asks before replacing a version it didn't upload). pnpm everyapp access off also deletes the two applications.
  • Passwords turned off and Access down: pnpm everyapp secret set EMAIL_PASSWORD_SIGN_IN with true.
  • Access signed the browser in as the wrong person: Not you? Sign out of Access on the sign-in page, or /cdn-cgi/access/logout. If the Gateway account has the wrong email and it is the owner's, change it in D1 (cf d1 query <id> --sql "UPDATE users SET email = 'x@acme.com' WHERE email = 'y@acme.com'"), then sign in with the password through Access once to link; for anyone else, invite the right address and deactivate the old account.
  • "This gateway signs people in through Cloudflare Access, and this request didn't come through it." (ACCESS_REQUIRED): Access isn't in front of that hostname, the path is bypassed, or the AUD tag is wrong. An application recreated in the dashboard has a new AUD tag: pnpm everyapp access on writes the settings again when the applications it finds aren't the ones its D1 record (_everyapp_access) names, pnpm everyapp access show says when they are stale (settings.current: false, "name an application that no longer exists: nobody can sign in"), and --write-settings writes them regardless.
  • Apps send people back to the sign-in page ("the page you were going to sent you back here"): the app hosts' Access application isn't one the Gateway knows; put its AUD in CLOUDFLARE_ACCESS_APPS_AUD.
  • No "Continue with" button: access.config_problem in the Worker's logs names the missing or malformed OIDC setting.
  • "Signing in with … didn't finish." The redirect URL in Access must be exactly https://<gateway>/api/auth/callback/cloudflare-access, PKCE on, and the issuer the application's.
  • Teammates can't log in at Access at all: the team's only provider is Sign in with Cloudflare, restricted to the Cloudflare account's members; access on adds the one-time PIN beside it, and adds both to applications made before. An operator who sees Access's forbidden page after Sign in with Cloudflare: their Cloudflare account's email isn't a Gateway member (ACCESS_LOGIN_NOT_MEMBER); use the emailed code, or invite that address.
  • MCP clients get an HTML login page: a bypass path is missing (access show reports "paths differ"; access on fixes it), or Managed OAuth isn't on.
  • WebSocket 403 behind the outer ring: the application is attached to the Worker; use hostname destinations.

Logs to grep: access.config_problem, access.jwks_fetch_failed, access.signed_in (mode: "outer-ring"), access.sign_in_refused (reason is a code or no_assertion/someone_else), access.session_ignored, access.session_unvouched, access.session_mismatch, access.session_replaced, access.account_linked, access.account_link_skipped (another request linked the identity first and won the unique index; the session stands; reason is the error's first 120 characters), access.member_joined (via: "auto_join_domain" or "outer_ring_everyone", and mode), invitation.accepted (via: "cloudflare_access"), member.deactivated (reason: "deleted_with_access_auto_join", from Delete user), mcp.access_assertion_ignored, mcp.access_caller_refused.

Open decisions

From TODO.md ("Decide", "MCP through Cloudflare Access (2026-10-20)", and "Cloudflare Access from the CLI (2026-10-23)"); each shipped with a default, to confirm or change:

  • Password accounts and Cloudflare Access. An existing password account has to connect Access once (sign in with the password through Access) before Access signs it in.
  • MCP through Access (Managed OAuth) defaults. The identity must already be linked by a browser sign-in (403 not_connected); /mcp never auto-joins; write access to every app the member can use; the organization rule; no connection row; the service-token path unverified live. Rejected for now: auto-join from /mcp; read-only by default with a requested scope.
  • access verbs: on (idempotent), off, show, rather than setup or the enable the redesign doc named.
  • Session defaults: 720h for new applications (the most Access allows), an existing application's kept unless --session; the team's global session never touched unless --team-session.
  • Sign-in methods: access on keeps both Sign in with Cloudflare (created with restrict_to_account_members: true when the team has none; an API-made team has none) and the one-time PIN (created when none), and allows only those two on both applications (the review of #622: the Zero Trust team is account-wide and the Gateway trusts the address Access asserts for account linking, so a provider on the team is no statement about who may sign in here), adding to an existing application's list and never taking a method off; auto_redirect_to_identity only with one provider. oidc/saml and a cloudflare provider open to any Cloudflare account are warned about (ACCESS_IDP_UNVERIFIED_EMAILS, ACCESS_IDP_ANY_CLOUDFLARE_ACCOUNT) and left off, their settings as the operator set them; any other provider (Okta) is listed as the team's, allowed: false, for the operator to allow in the dashboard. Alternatives: the PIN alone (no chooser page); every provider of the team (the first build of #622).
  • Access is the default (2026-10-26, Ben: "default secure always if they enabled that"). setup (and a deploy that installs a Gateway) puts Access in front once the owner exists, on a self-hosted Gateway without the three settings: asked at a terminal (Yes by default), done without a question under --yes, --json and CI when the account can (a Zero Trust team exists, since a new team's name is permanent and never picked for someone, and the Cloudflare login is an owner or admin of the Gateway, so Sign in with Cloudflare gets them in), else one warning ACCESS_SETUP_SKIPPED says why and names access on; --no-access opts out, access off undoes it; update never offers it. Any failure is the warning, never a failed setup. Once on, update keeps both applications in step (above), and status says when they drift.
  • Who is let in: the policy gains members, invitees, --email and --email-domain, never loses anyone (alternative: a --prune); --email-domain doesn't set CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS.
  • ACCESS_NOBODY_ALLOWED when there is nobody to let in and no policy yet.
  • Applications found by destination, one covering both kept as one; foreign destinations kept; inline-policy applications skipped.
  • Public routes as Access patterns (** → *, :param → *, methods ignored), the 25-override limit; the wildcard *.<base> bypasses nothing, an app host's /mcp only where its manifest sets mcp, nothing bypasses /mcp once Managed OAuth is on; the Gateway's /health always.
  • Settings written when one is missing, an application was created, nothing is recorded yet, the record doesn't name the applications found, or --write-settings; what was written is recorded in the Gateway's D1 (_everyapp_access), and access show says when the settings are stale. Alternative: write them on every run (one Worker version per rerun, no record).
  • Deploys and deletes sync the apps application under the access lease, as a warning on failure; the policy only by access on.
  • access off removes settings first, then applications; --keep-applications; asks unless --yes.
  • Not done: a renamed policy means a second one is created; listAll pages applications, policies and identity providers 100 × 20 (PAGE = { perPage: 100, maxPages: 20 }) without saying when it stops short, so past 2000 of one kind they could be misread; Managed OAuth isn't offered by access on (an option for it is a TODO); the apps' application can't take Managed OAuth until the perimeter accepts the assertion on <app>.<gateway>/mcp.

Sources

  • docs/runbooks/cloudflare-access.md
  • docs/runbooks/mcp-oauth.md
  • docs/security-model.md
  • TODO.md
  • apps/every-app-gateway/src/auth/cloudflare-access/config.ts
  • apps/every-app-gateway/src/auth/cloudflare-access/index.ts
  • apps/every-app-gateway/src/auth/cloudflare-access/jwt.ts
  • apps/every-app-gateway/src/auth/cloudflare-access/outer-ring.ts
  • apps/every-app-gateway/src/auth/cloudflare-access/membership.ts
  • apps/every-app-gateway/src/auth/cloudflare-access/plugin.ts
  • apps/every-app-gateway/src/auth/cloudflare-access/oidc-provider.ts
  • apps/every-app-gateway/src/auth/config.ts
  • apps/every-app-gateway/src/auth/sign-in-methods.ts
  • apps/every-app-gateway/src/client/sign-in-methods.ts
  • apps/every-app-gateway/src/client/components/Sidebar.tsx
  • apps/every-app-gateway/src/routes/sign-in.tsx
  • apps/every-app-gateway/src/server/platform-policy.ts
  • apps/every-app-gateway/src/server/password-sign-in.ts
  • apps/every-app-gateway/src/server/access-rejoin.ts
  • apps/every-app-gateway/src/server/services/AdminService.ts
  • apps/every-app-gateway/src/server/mcp-auth/routes.ts
  • apps/every-app-gateway/src/server/mcp-auth/access-caller.ts
  • apps/every-app-gateway/src/server/mcp-auth/validate.ts
  • apps/every-app-gateway/src/server/toolbus/gateway-mcp.ts
  • packages/perimeter/src/headers.ts
  • packages/sdk/src/internal/gatewaySettings.ts
  • packages/cli/src/commands/access/command.ts
  • packages/cli/src/commands/access/index.ts
  • packages/cli/src/commands/access/index.test.ts
  • packages/cli/src/lib/access/plan.ts
  • packages/cli/src/lib/access/record.ts
  • packages/cli/src/lib/access/sync.ts
  • packages/cli/src/lib/access/settings.ts
  • packages/cli/src/lib/cf/access.ts
  • packages/cli/src/commands/status/index.ts
  • packages/cli/src/commands/status/problems.ts
  • packages/cli/src/commands/status/render.ts
  • packages/cli/src/lib/gateway/lease.ts
  • packages/cli/src/lib/output/events.ts
  • packages/cli/src/lib/output/command.ts
  • packages/cli/src/lib/output/prompt.ts
  • packages/cli/src/lib/errors.ts
  • packages/cli/src/commands/secret/command.ts
  • packages/cli/src/commands/secret/index.ts
  • packages/cli/src/commands/deploy/deployApp.ts
  • packages/cli/src/commands/deploy/steps/waitForLive.ts
  • packages/cli/src/commands/delete/index.ts
  • packages/cli/src/commands/setup/runSetup.ts
  • packages/cli/src/lib/domains/https.ts
  • packages/cli/skills/everyapp/SKILL.md
  • landing-page/content/docs/access.mdx
  • landing-page/content/docs/people.mdx

On this page