EveryAppEveryApp

Sign-in providers: Continue with Google, Microsoft, GitHub, Apple, Cloudflare or SSO

How a Gateway offers Continue with Google, Microsoft, GitHub, Apple, Cloudflare or any OpenID Connect issuer beside passwords: the catalog and every <ID>_SIGNIN_<FIELD> setting, the redirect URL, every everyapp signin command, flag, --json shape and code, who gets in and how existing accounts connect, Microsoft's tenant rule, Apple's POST and expiring secret, a private Cloudflare client versus Access's Sign in with Cloudflare, the generic OIDC issuer, Manage workspace → Sign-in, the read-only MCP tools, turning passwords off, and the threat model's rows.

A sign-in provider is one more way to sign in to a Gateway: "Continue with Google" on the sign-in page, and the same for Microsoft, GitHub, Apple, Cloudflare and any OpenID Connect issuer (Okta, Entra ID as an issuer, Keycloak, Auth0, Authentik, Google Workspace as an issuer). It is not a second login system. Better Auth stays the system of record for users, sessions, organizations, roles, invitations, personal access tokens and the OAuth server MCP clients use; Better Auth's own endpoints run the flow, and a provider 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 stay on unless EMAIL_PASSWORD_SIGN_IN turns them off. Providers register in both deployment modes, self-hosted and hosted.

The person's page is Sign in with Google, Microsoft, GitHub, Apple or your SSO; the operator's runbook in the checkout is docs/runbooks/sign-in-providers.md; the security rules are the security model's "Sign-in providers" and the threat model's boundary B10 (/docs/agent/repo/threat-model). Cloudflare Access is a different thing with its own settings and page: Cloudflare Access.

The model

  • The catalog. SIGN_IN_PROVIDERS in packages/sdk/src/internal/signInProviders.ts is the one list, as data: six ids (SIGN_IN_PROVIDER_IDS: google, microsoft, github, apple, cloudflare, oidc, in that order), and for each its button label ("Continue with
  • The settings are Worker secrets on the Gateway, named <ID>_SIGNIN_<FIELD> with FIELD one of CLIENT_ID, CLIENT_SECRET, ISSUER, LABEL or TENANT (SIGN_IN_SETTING_PATTERN, isSignInSettingName). The whole family is reserved, whether or not a catalog entry reads the name today: a registered API provider row can't take one (PROVIDER_SECRET_TAKEN), nor can an app's manifest (secrets refuses it: "is a sign-in provider's setting name"). The _SIGNIN_ infix keeps them apart from the OAuth clients the Gateway holds for apps (GOOGLE_WORKSPACE_CLIENT_ID), the API-key providers' <ID>_API_KEY and the MCP apps' <ID>_MCP_TOKEN. Every name is in SIGN_IN_GATEWAY_SETTINGS, and so in SETTABLE_GATEWAY_SETTINGS: pnpm everyapp secret set <NAME> routes it to the Gateway Worker as well as everyapp signin add does. The client ids, the issuer, the label and the tenant are plain (PLAIN_SIGN_IN_SETTINGS: shown as typed); the client secrets are hidden.
  • Only the Cloudflare login writes them. Nothing in the Gateway's UI, no server function and no MCP tool sets or removes a sign-in setting, so a stolen admin session or agent token can't add a way in. Each change is a new Worker version, read on its next request.
  • Three states (signInProviderState()): on when every required setting is set (and, as the Gateway reads them, well formed), incomplete when some are set and a required one is missing, or one is malformed, off when none is set. The Gateway never half-configures a sign-in: an incomplete provider is off, it logs signin.config_problem (level warn, once per isolate) and Manage workspace → Sign-in shows it as Needs setup. The CLI sees only the secrets' names (a Worker's values can't be read back), so signin list reports on, incomplete or off from which names are set; a malformed value is caught when signin add takes it, and only the Gateway (its log, the Sign-in tab, everyapp_signin_list) can see one set another way.
  • The Gateway reads them in readSignInProviders() (apps/every-app-gateway/src/auth/sign-in-providers/config.ts), registers the providers that are on as Better Auth social providers (providers.ts; the generic OIDC one is the Gateway's own, oidc-provider.ts) and applies the membership rules below through database hooks (membership.ts, plugin.ts). GET /api/auth/sign-in-methods lists one { id, label } per provider that is on, Cloudflare Access's OIDC button first, then the catalog's order; the sign-in page draws a button for each.
  • One flow. Better Auth's /api/auth/sign-in/social, /api/auth/callback/<id> and /api/auth/link-social, with state and PKCE (S256; GitHub: state), the code redeemed with the client secret. No provider accepts an ID token posted by the browser (disableIdTokenSignIn), and only identity scopes are asked for: no token the Gateway holds can act for anyone, and no refresh token is kept.

The providers

Id Button The OAuth app, and where Scopes Email, and when it counts as verified Account key
google Continue with Google OAuth client ID (Web application), https://console.cloud.google.com/apis/credentials openid email profile The ID token's email, only with email_verified: true (every Google account's own address is). The account chooser opens every time (prompt=select_account). Google's sub
microsoft Continue with Microsoft App registration, https://entra.microsoft.com (App registrations) openid profile email; Better Auth's default Graph and offline scopes removed The ID token's email, by the tenant rule (below): in the pinned tenant, its own directory's word; on common, organizations or consumers, only with xms_edov: true, email_verified: true, a matching verified_primary_email, or a personal Microsoft account. No profile photo is fetched. Entra's oid
github Continue with GitHub OAuth App (not a GitHub App), https://github.com/settings/developers read:user user:email The primary address from GitHub's emails API, only when GitHub marks it verified. GitHub's numeric user id
apple Continue with Apple Services ID, https://developer.apple.com/account/resources/identifiers/list/serviceId email name The ID token's email, only with email_verified: true. Hide My Email arrives as an @privaterelay.appleid.com address, which no invitation names. Apple's sub
cloudflare Continue with Cloudflare OAuth client (Manage Account → OAuth clients), https://dash.cloudflare.com/?to=/:account/oauth-clients user-details.read The email of the Cloudflare API's /user (the account's sign-in address). Cloudflare reports no verified flag: the address counts on the strength of the client being private (below). Cloudflare's user id
oidc Continue with SSO OpenID Connect client (web application, authorization code), in the identity provider's admin console openid email profile The ID token's email, else the userinfo endpoint's (for the same sub only), only with email_verified: true. The label is OIDC_SIGNIN_LABEL. The issuer's sub

Every setting, from the catalog. "Required" means the provider is off without it; "Shown" means signin add and secret set show it as typed. The "Problem when wrong" texts are the signin.config_problem lines readSignInProviders() logs, also shown in Manage workspace → Sign-in.

Setting Provider Required Shown Holds Problem when wrong
GOOGLE_SIGNIN_CLIENT_ID google yes yes The OAuth client's Client ID (ends in .apps.googleusercontent.com). With either Google setting missing: Sign in with Google needs GOOGLE_SIGNIN_CLIENT_SECRET; it is off. (the missing names, joined with "and")
GOOGLE_SIGNIN_CLIENT_SECRET google yes no The OAuth client's Client secret. As above.
MICROSOFT_SIGNIN_CLIENT_ID microsoft yes yes The app registration's Application (client) ID. Sign in with Microsoft needs …; it is off.
MICROSOFT_SIGNIN_CLIENT_SECRET microsoft yes no A client secret from Certificates & secrets. It expires (24 months at most): set a reminder. As above.
MICROSOFT_SIGNIN_TENANT microsoft no yes The Directory (tenant) ID, a GUID, to pin sign-in to the operator's own directory; or common (the default when unset: any Microsoft account), organizations or consumers. Case-insensitive. MICROSOFT_SIGNIN_TENANT must be a Directory (tenant) ID, or common, organizations or consumers; Microsoft sign-in is off.
GITHUB_SIGNIN_CLIENT_ID github yes yes The OAuth App's Client ID. Sign in with GitHub needs …; it is off.
GITHUB_SIGNIN_CLIENT_SECRET github yes no A client secret generated on the OAuth App. As above.
APPLE_SIGNIN_CLIENT_ID apple yes yes The Services ID's identifier (com.example.gateway). Sign in with Apple needs …; it is off.
APPLE_SIGNIN_CLIENT_SECRET apple yes no The client secret JWT generated from the Sign in with Apple key (ES256; valid six months at most, then generate it again). As above. An expired JWT isn't a settings problem: Apple refuses the code exchange and the sign-in fails.
CLOUDFLARE_SIGNIN_CLIENT_ID cloudflare yes yes The OAuth client's Client ID. Keep the client private. Sign in with Cloudflare needs …; it is off.
CLOUDFLARE_SIGNIN_CLIENT_SECRET cloudflare yes no The OAuth client's Client secret. As above.
OIDC_SIGNIN_ISSUER oidc yes yes The issuer URL: https://…, no credentials, query or fragment, trailing slashes dropped (a loopback http:// only in vite dev). It serves /.well-known/openid-configuration, which must name the same issuer. OIDC_SIGNIN_ISSUER must be an https URL (the issuer, which serves /.well-known/openid-configuration); SSO sign-in is off.; missing: Sign in with SSO needs …
OIDC_SIGNIN_CLIENT_ID oidc yes yes The client ID registered with that issuer. As above.
OIDC_SIGNIN_CLIENT_SECRET oidc yes no Its client secret. As above.
OIDC_SIGNIN_LABEL oidc no yes The button text after "Continue with"; default SSO. The Gateway trims a longer one to 40 characters; signin add and secret set refuse it (SETTING_VALUE_INVALID). None (empty is the default).

The redirect URL

Every provider's OAuth app is registered with one redirect URL (also called the callback, return or Authorization callback URL), always https://<gateway>/api/auth/callback/<id>: for a Gateway at apps.acme.com and Google, https://apps.acme.com/api/auth/callback/google. <gateway> is the Gateway's own host (GATEWAY_URL), never an app host or *.cloudflareaccess.com; it must match exactly, scheme and path included, or the provider answers redirect_uri_mismatch (or its own word for it). signInCallbackUrl() in the catalog builds it; pnpm everyapp signin show <id> prints it, signin list --json carries it as callbackUrl, and Manage workspace → Sign-in shows it in each provider's Needs setup dialog. Apple's Services ID also takes the Gateway's domain under Domains and Subdomains.

Set up a provider

The same four steps for each; the everyapp skill's step-by-step guides, which an agent follows for the person, are setup/signin-google.md, setup/signin-microsoft.md, setup/signin-github.md, setup/signin-apple.md, setup/signin-cloudflare.md and setup/signin-oidc.md (the catalog's setup field; in the checkout under packages/cli/skills/everyapp/, in an app .claude/skills/everyapp/ and .agents/skills/everyapp/).

  1. The person creates the OAuth app at the provider's console, with the redirect URL above. Only they can: it is their Google Cloud project, Entra tenant, GitHub account, Apple Developer team, Cloudflare account or identity provider.
  2. pnpm everyapp signin add <id> sets the settings on the Gateway Worker in one new version (at a terminal it asks for each; unattended, the flags below).
  3. The person opens the sign-in page: the button is there. Signing in with an invited address checks the whole path.
  4. Behind the Cloudflare Access outer ring nothing else changes: a provider sign-in makes a session only for the person Access vouches for on the request, so the owner claims and invites with the addresses Access knows people by.

Microsoft: the tenant and xms_edov

Which Microsoft addresses count as verified depends on MICROSOFT_SIGNIN_TENANT (microsoftEmailVerified() in providers.ts):

  • Pinned to the operator's own directory (recommended): register the app for "Accounts in this organizational directory only" and set the setting to the Directory (tenant) ID. A token counts when its tid is that tenant: the directory's addresses are trusted as Access trusts the operator's identity provider. A token from any other tenant counts as unverified.
  • Any Microsoft account (unset, common, organizations or consumers): any tenant's admin can put any address on their users (the "nOAuth" attack), so the Gateway needs the token's own word for the address: xms_edov: true (the optional claim "email domain owner verified"), email_verified: true, the email in verified_primary_email, or a personal Microsoft account (tid 9188040d-6c67-4c5b-b112-36a304b66dad). Add the optional claims email and xms_edov to the ID token under the registration's Token configuration; without them every work or school sign-in is refused as SIGNIN_EMAIL_NOT_VERIFIED.

The default is common with that rule rather than refusing to start without a tenant; requiring the tenant is the walk-back in TODO.md.

Apple: the POST and the six-month secret

Apple returns to the Gateway with a POST (response_mode=form_post); Better Auth answers it with a redirect to the same URL as a GET, where the sign-in completes. Behind the outer ring, everyapp access on lists /api/auth/callback/apple among the bypassed paths so the POST reaches the Gateway (it creates nothing; the GET that follows is checked by Access as usual); a ring made by hand without that bypass blocks Apple sign-in.

The client secret is a JWT the operator signs with the Services ID's Sign in with Apple key: ES256, header kid the Key ID, iss the Team ID, sub the Services ID, aud https://appleid.apple.com, exp at most six months after iat. The setup guide has the command. It expires: generate a new one and run pnpm everyapp signin add apple again before then, or every Apple sign-in fails at the code exchange. Apple was shipped without a live run (only against mocked endpoints).

Cloudflare: a private OAuth client

Cloudflare dashboard → Manage Account → OAuth clients → Create client, which takes Super Administrator, Administrator or the OAuth Client Write role: authorization code, client_secret_basic, the redirect URL, and the scope User Details Read (user-details.read) as required.

Keep the client private (the default). A private client can only be authorized by members of the operator's Cloudflare account, who joined it by accepting an invitation sent to their address: that is what makes the address trustworthy, since Cloudflare's /user reports no verified flag, and the Gateway marks it verified on that ground (providers.ts). Making a client public (any Cloudflare user; it needs a logo, a client URL and a DNS TXT verification) is permanent and would let anyone present an address they never verified; the Gateway can't tell the two kinds apart. So this is Continue with Cloudflare for the members of the operator's Cloudflare account; everyone else uses another provider, a password or Access's one-time PIN. Dropping the cloudflare entry, or requiring the Access identity provider instead, is the walk-back in TODO.md.

Access's "Sign in with Cloudflare" versus the cloudflare provider

Two different things share the name, and both can be on:

Access's "Cloudflare" identity provider The cloudflare sign-in provider
What it is A login in front of the Gateway: Cloudflare Access signs the browser in on <team>.cloudflareaccess.com before any request reaches the Gateway, which signs the person in from the assertion Access adds A button on the Gateway's own sign-in page, through a Cloudflare OAuth client in the operator's account: a social login like Google's
What it protects Every page and app behind the outer ring The sign-in only; nothing sits in front of the Gateway
Who can use it Members of the operator's Cloudflare account ("Restrict to account members" on); everyone else gets Access's one-time PIN Members of the operator's Cloudflare account (a private client)
Costs A Zero Trust seat per person (50 free) Nothing
MFA and device rules Access's The person's Cloudflare account's own MFA
Set up with pnpm everyapp access on; settings CLOUDFLARE_ACCESS_* (Cloudflare Access) pnpm everyapp signin add cloudflare; settings CLOUDFLARE_SIGNIN_CLIENT_ID, CLOUDFLARE_SIGNIN_CLIENT_SECRET
Account key The verified email (Access changes sub when someone is removed and added back) Cloudflare's user id

With the outer ring on the button is rarely needed: Access has already signed the person in. Without Access, it is the quickest way for the account's members to get in with no password.

Any OpenID Connect issuer (oidc)

For Okta, Entra ID as an issuer, Keycloak, Auth0, Authentik, Google Workspace as an issuer and the like: an OpenID Connect client (web application, authorization code) with the redirect URL https://<gateway>/api/auth/callback/oidc, then OIDC_SIGNIN_ISSUER, OIDC_SIGNIN_CLIENT_ID, OIDC_SIGNIN_CLIENT_SECRET and optionally OIDC_SIGNIN_LABEL. The provider is the Gateway's own (oidc-provider.ts), not Better Auth's generic one:

  • Discovery is fetched when someone starts a sign-in, never when the Gateway starts, with a 5-second timeout, and cached for an hour. The document must name the configured issuer and https endpoints; otherwise, or when the issuer is down, the sign-in answers SIGNIN_PROVIDER_UNAVAILABLE, nothing is cached and the next attempt tries again.
  • The ID token is verified in full before any claim is used: RS256 only (every major issuer's default), by the issuer's JWKS through the Access verifier's fail-closed key cache, issuer, audience equal to the client id, expiry, sub, and the nonce the sign-in started with. A userinfo answer counts only for the same sub.
  • One issuer. A second one would be OIDC2_…, not shipped (TODO.md). Cloudflare Access for SaaS is an OIDC issuer too but keeps its own settings (CLOUDFLARE_ACCESS_OIDC_*, Cloudflare Access): use those for Access and OIDC_SIGNIN_* for everything else.

everyapp signin

A top-level command group, like access, email and events (packages/cli/src/commands/signin/; the value checks in packages/cli/src/lib/signinValues.ts). It works under the Cloudflare login on the Gateway Worker's secrets, through the same lease, replaceable-version check and cf workers secrets bulk as secret set (applyGatewaySettings()), and never connects to the provider. It reads which settings are set from the Worker's secret names alone, since a value can't be read back. Every command takes --gateway, --account-id, --json and --verbose. The exact text of each is pnpm everyapp signin <command> --help and the CLI reference. The commands call the state needs setup where the catalog says incomplete.

Command Does
signin add <id> [--client-id <id>] [--client-secret-from-env <VAR>] [--issuer <url>] [--label <text>] [--tenant <id>] Turns a provider on: sets the settings given on the Gateway Worker, all in one new version (step signin.add), live on its next request. Flags are checked before anything connects. At a terminal it first says what to create at which console and the redirect URL to register, then asks for every setting no flag gave, in catalog order: a plain one shown as typed and checked as it is typed, the client secret hidden; Enter keeps a setting that is set already, and leaves an optional one (Microsoft's tenant, the SSO label) as it is. Unattended (--json, or no terminal) it asks nothing: every required setting not already on the Worker comes from a flag, else SIGNIN_VALUE_REQUIRED with a rerun that names the missing flags. The client secret never goes on the command line: --client-secret-from-env <VAR> names the variable holding it. --issuer and --label are oidc's, --tenant is microsoft's; a flag the provider doesn't read is SIGNIN_FLAG_NOT_APPLICABLE. Safe to repeat: it replaces the values given (a rotated secret, a new client) and keeps the others (secret unset <NAME> removes an optional one). data: { id, label, status: "on", changed, set: [names written], kept: [names left as they were], callbackUrl, signInUrl, notes, setup }; with nothing given and the provider on already, changed: false and nothing is written. When the provider wasn't on before, it queues the event signin_provider.added.
signin list (alias ls) Every catalog provider, in catalog order, with its state and the settings set or missing. data.providers: [{ id, label, status: "on" | "needs setup" | "off", missing: [names, only while it needs setup], set: [names], callbackUrl, setup }] (setup the guide, like setup/signin-google.md), data.signInUrl, and data.next ([{ command: "pnpm everyapp signin add <id>", note }]) for each provider that needs setup. A value set by hand that the Gateway won't read (a malformed issuer, say) still lists as on here; the Gateway keeps the provider off and says so in its log, in Manage workspace → Sign-in and in everyapp_signin_list.
signin show <id> One provider in full, changing nothing: its state, the console, what to create there, the redirect URL, the scopes, which claim becomes the email and when it counts as verified, the guide, then every setting with whether it is required, whether it is set and what it holds, and the caveats. data: { id, label, status, missing, set, console, appNoun, callbackUrl, scopes, emailClaim, settings: [{ name, role, required, plain, what, set }], notes, setup }, plus next (pnpm everyapp signin add <id>) unless it is on.
signin remove <id> [--yes] Turns a provider off: removes every setting of it the Worker holds, in one new version (step signin.remove); the button leaves the sign-in page on the next request. It asks first (Turn Sign in with <label> off?); unattended it needs --yes (-y), else CONFIRMATION_REQUIRED, and a "no" is CANCELLED. People who signed in with it keep their accounts and sign in another way (a password through Forgot password, another provider, Access); their linked identities stay, and work again if the provider comes back. Delete the OAuth app at the provider too. data: { id, removed: [names] }; with nothing set, removed: [] and nothing changes. When the provider was on, it queues the event signin_provider.removed.

The flags, together: --client-id, --client-secret-from-env, --issuer, --label, --tenant, --yes, --gateway, --account-id, --json, --verbose.

pnpm everyapp secret set <NAME> takes the same names, one at a time, with the same checks on the plain ones (SETTING_VALUE_INVALID there); signin add is the way to set a provider's settings together, in one version.

Examples:

# At a terminal: says where to make the OAuth app, then asks for the client id and the hidden secret
pnpm everyapp signin add google

# Unattended: the secret from a variable, never on the command line
pnpm everyapp signin add github --client-id Iv1.0123456789abcdef --client-secret-from-env GITHUB_SECRET --json

# Microsoft pinned to one directory
pnpm everyapp signin add microsoft --tenant 11111111-2222-3333-4444-555555555555

# Okta as the SSO button
pnpm everyapp signin add oidc --issuer https://acme.okta.com --label Okta

pnpm everyapp signin list --json
pnpm everyapp signin show apple
pnpm everyapp signin remove github --yes

Driving it as an agent: the person makes the OAuth app (only they can sign in to their Google Cloud, Entra, GitHub, Apple or Cloudflare account); give them the console, what to create and the redirect URL from signin show <id> --json (console, appNoun, callbackUrl) and follow the setup guide. Ask them to paste the client id; the secret goes in an environment variable (--client-secret-from-env) or the terminal's hidden prompt, never the chat. Then signin add, then have them open the sign-in page (signInUrl) and sign in with an invited address. The settings never go through configure, which is for apps.

Error codes

The codes everyapp signin raises; each has its entry on the error codes page.

Code Means
SIGNIN_UNKNOWN_PROVIDER The id isn't in the catalog. The message lists the six (data.providers); any other OpenID Connect issuer is oidc. rerun: signin list.
SIGNIN_VALUE_REQUIRED signin add with a required setting neither given nor set already: unattended (data.missing, data.flags, data.callbackUrl, and a rerun with the flags to add), or left empty at the prompt. Nothing was changed.
SIGNIN_FLAG_NOT_APPLICABLE A flag the provider doesn't read: --issuer or --label for anything but oidc, --tenant for anything but microsoft (data.flags).
SIGNIN_ISSUER_INVALID The issuer isn't an https:// URL without a user name, password, query or fragment, or is on localhost or 127.0.0.1 (the Gateway's loopback allowance is for local development only).
SIGNIN_TENANT_INVALID The tenant isn't a Directory (tenant) ID (a GUID) or common, organizations or consumers; a domain like acme.onmicrosoft.com is refused.
SETTING_VALUE_INVALID --label (or a typed label) is over 40 characters.
SECRET_EMPTY The variable --client-secret-from-env names is empty or unset; checked before anything connects.
CONFIRMATION_REQUIRED signin remove unattended without --yes.
CANCELLED "No" to signin remove's question: Cancelled. Sign in with <label> stays as it was.

Who gets in

A provider only says who someone is: an email it verified. Whether they get a session is the Gateway's decision, made by Better Auth database hooks before the user row and before the session (assertSignInUserMayBeCreated() and ensureSignInMembership() in apps/every-app-gateway/src/auth/sign-in-providers/membership.ts, sharing identity-membership.ts with Access), so a refused sign-in leaves nothing behind. The invitation flow's rules hold:

Who Result
A member who isn't deactivated Signs in (an identity linked to their account, or one linking by the rules below).
A deactivated member Refused, SIGNIN_ACCOUNT_DEACTIVATED ("This account has been deactivated."), whatever the provider says (Better Auth's ban check answers first, BANNED_USER).
Someone with a pending, unexpired invitation for that exact email Joins: every such invitation is accepted (its 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, like accepting the invite link. No link and no password needed. Logged invitation.accepted with via: "sign_in:<id>"; event member.joined with via: "sign_in".
An email the provider hasn't verified Refused, SIGNIN_EMAIL_NOT_VERIFIED ("The sign-in provider hasn't verified this email address, so it can't be used here."), invited or not; no user is created.
On the hosted Gateway (open sign-up), anyone with a verified email Gets an account and a personal organization, as the sign-up form gives (HOSTED_OPEN_SIGNUP; a self-hosted Gateway is invite-only).
Everyone else Refused, SIGNIN_NOT_INVITED ("You don't have an account on this gateway. Ask an admin to invite you."); nothing is created. There is no auto-join by email domain for sign-in providers (Access keeps its own, CLOUDFLARE_ACCESS_AUTO_JOIN_DOMAINS).
Anyone, before the Gateway has been claimed with its setup link Refused, SIGNIN_NOT_SET_UP ("This gateway hasn't been set up yet: its owner signs in first."). The first owner is still made by everyapp setup.

A refusal logs signin.sign_in_refused (provider, reason: the code without its prefix, lowercased, like not_invited); a sign-in logs signin.signed_in (provider, userId, joined: true when it accepted an invitation).

What the sign-in page explains

A refused or failed provider sign-in comes back to the sign-in page as ?error=<code>, and the page says what happened in a sentence (signInErrorMessage() in apps/every-app-gateway/src/client/sign-in-methods.ts; <label> is the button's):

?error= The sign-in page says
SIGNIN_NOT_INVITED You don't have an account on this gateway yet. Ask an admin to invite you. The address the provider returned isn't a member's and has no pending invitation: invite it exactly as the provider spells it, or have the person use the account the provider knows them by.
SIGNIN_NOT_SET_UP This gateway hasn't been set up yet: its owner has to claim it first.
SIGNIN_ACCOUNT_DEACTIVATED, BANNED_USER This account has been deactivated. Ask an admin to reactivate it.
SIGNIN_EMAIL_NOT_VERIFIED, email_not_verified <label> hasn't verified this email address, so it can't be used here. Google or GitHub reports it unverified, Microsoft on common lacks the optional claims, the OIDC token lacks email_verified: true.
SIGNIN_PROVIDER_UNAVAILABLE <label> couldn't be reached. Try again in a moment. The OIDC issuer's discovery document couldn't be fetched, or names another issuer than OIDC_SIGNIN_ISSUER.
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 connects the provider after the password sign-in). Passwords off: an admin has to help (below).
email_does_not_match That <label> identity has a different email than this account, so it wasn't connected. (the account menu's Connect)
unable_to_link_account <label> hasn't verified that email, so it wasn't connected. (Connect)
account_already_linked_to_different_user That <label> identity is already connected to another account here, so it wasn't connected to this one. (Connect)
PASSWORD_SIGN_IN_DISABLED Password sign-in is turned off on this gateway. Continue with <label>.
ACCESS_EMAIL_MISMATCH Behind the outer ring: the provider's address isn't the one Cloudflare Access asserts for this browser.

Existing accounts: connect once

Accounts are keyed by the provider's stable subject (the providers table's last column), not the email, so a person whose address changes at the provider keeps their account and a recycled address inherits nothing. This differs from Access, which keys by the verified email.

A provider identity links to an existing account with the same email only when the provider reports the email verified and that account's own email is verified: Better Auth's rule for a provider that isn't trusted by name (requireLocalEmailVerified; none of the six is in trustedProviders). Accounts created with a password, the owner's from the setup link included, aren't verified, so someone who registered with another person's address never gets that person's account. Each such account connects once, while signed in:

  • From the button: Continue with
  • From the account menu: signed in, Connect . The link refuses an identity with another email (email_does_not_match) or one the provider hasn't verified.

After that the provider alone signs them in, and the first such sign-in marks the account's email verified (the provider vouched for the same address). People who join through a provider never get a password; they can set one with Forgot password if the Gateway sends email.

Deactivate, not the provider

Deactivate in Manage workspace → Users → ⋯ → Deactivate: that signs the person out everywhere and revokes their personal access tokens and AI connections, whatever the provider says, and their next Continue with

Behind Cloudflare Access

The outer ring stays in front, unchanged: the Access plugin's hooks run on a provider's callback like on any other request, so a provider sign-in makes a session only for the person Access vouches for, and none without an assertion (ACCESS_EMAIL_MISMATCH otherwise). Claim and invite with the addresses Access knows people by. Apple's POST callback is the one sign-in path the ring bypasses (above).

Turning passwords off: EMAIL_PASSWORD_SIGN_IN

EMAIL_PASSWORD_SIGN_IN=false (pnpm everyapp secret set EMAIL_PASSWORD_SIGN_IN) closes password sign-in, sign-up and resets over HTTP (PASSWORD_SIGN_IN_DISABLED). It takes effect only while an Access sign-in method or a sign-in provider is on and some owner has connected such an identity (isPasswordSignInOn() in apps/every-app-gateway/src/auth/cloudflare-access/membership.ts), so it can't lock the owner out: connect first (the account menu), then set it. With neither on it is ignored with a warning. Anyone not yet connected can't sign in once it takes effect, since connecting a password account goes through a password sign-in: have everyone connect first. true turns passwords back on. The full rules, every value it takes and its messages are under Cloudflare Access.

Manage workspace → Sign-in

The Gateway's Sign-in tab (/admin/sign-in, owners and admins; apps/every-app-gateway/src/routes/_app/admin/sign-in.tsx) lists the ways in as the Gateway reads them: password sign-in (on, off, or off once an owner has connected another way in), Cloudflare Access's button when it is on, and every catalog provider as On, Off or Needs setup (with the signin.config_problem text when a value is malformed). A provider opens a dialog with what to create at which console, the redirect URL (with a copy button), each setting by name as Set or Not set (never a value) and the command, pnpm everyapp signin add <id>, to copy. The tab writes nothing: the settings are Worker secrets, set under the Cloudflare login. On the sign-in page each provider that is on is one Continue with button, Access's first; in the account menu, Connect links each offered provider the person hasn't connected yet.

The MCP tools

The Gateway connector has two read-only tools for owners and admins (minRole: "admin", packages/toolbus/src/signin.ts, handlers in apps/every-app-gateway/src/server/toolbus/signin-tools.ts), the twins of signin list and signin show, answering from the Gateway's own reading of its settings, so they also see a malformed value the CLI can't. Both work in either deployment mode.

Tool Input Answers
everyapp_signin_list {} Every catalog provider with its status (on, off, incomplete: needs setup), its settings by name with whether each is set, the redirect URL, the command that sets it up and, when a value is malformed, the problem; also whether password sign-in is on (and whether EMAIL_PASSWORD_SIGN_IN=false waits for an owner to connect another way in), whether Cloudflare Access sign-in is on, and who runs signin add (self_hosted or hosted).
everyapp_signin_show { id }, one of the six ids One provider as signin show gives it: status, each setting's name, what it holds and whether it is set, the redirect URL, the console and what to create there, the scopes, the email rule, the caveats, the setup guide, the command and, for one that needs setup, what is wrong.

Neither returns a setting's value. There is no add or remove twin, by design: the settings are Worker secrets the Gateway can't write to itself, and a tool that could would let a stolen agent token add a way in (threat model B10, signin.tools.read-state-only). An agent connected over MCP tells the person to run pnpm everyapp signin add <id> from the checkout. A Gateway-held Cloudflare API token would make write twins possible; that is the walk-back in TODO.md.

Events and logs

  • signin_provider.added and signin_provider.removed (data: { provider, label }, never a setting), queued by signin add when the provider wasn't on before and by signin remove when it was, to the install organization, actor the operator (Events). A rerun that rotates a secret queues nothing.
  • member.joined with via: "sign_in" when a provider sign-in accepts an invitation.
  • The Gateway's log: signin.signed_in (provider, userId, joined), signin.sign_in_refused (provider, reason), signin.config_problem (problem, level warn, once per isolate) and invitation.accepted with via: "sign_in:<id>".

Security

The rules are the security model's "Sign-in providers" (/docs/agent/repo/security-model); the boundary is the threat model's B10, "Sign-in provider → the Gateway", with 17 invariants: signin.settings.secrets-only-family, signin.settings.all-or-off, signin.membership.nothing-before-claim, signin.membership.members-and-invitees-only, signin.membership.verified-email-only, signin.membership.deactivated-refused, signin.linking.untrusted-provider-rules, signin.account.keyed-by-subject, signin.flow.state-pkce-nonce, signin.oidc.id-token-verified, signin.oidc.discovery-names-issuer, signin.no-posted-id-token, signin.scopes.identity-only, signin.microsoft.verified-address-rule, signin.passwords-off.lockout-protection, signin.access-ring-unchanged, signin.tools.read-state-only. Its known gaps: Google, Microsoft, GitHub and Apple have run against mocked endpoints only (the generic OIDC provider is the Gateway's own and tested in full); Apple's POST callback needs its bypass behind the outer ring; signin add and signin remove have no MCP twin.

Open decisions

Taken as defaults during launch week and listed in TODO.md ("Sign-in providers (2026-10-25)"), each with its walk-back:

  • No auto-join by email domain for sign-in providers.
  • signin add and signin remove have no MCP twin.
  • Cloudflare's address is trusted on the strength of a private OAuth client.
  • Apple shipped without a live run, with its six-month secret.
  • Microsoft defaults to common with the xms_edov rule rather than requiring a tenant.
  • Providers register in both deployment modes; on the hosted Gateway's open sign-up a provider sign-in creates an account.
  • One generic OIDC entry, not several.
  • A provider sign-in after a signed-in Connect verifies the account's email.

Sources

packages/sdk/src/internal/signInProviders.ts packages/sdk/src/internal/gatewaySettings.ts apps/every-app-gateway/src/auth/sign-in-providers/config.ts apps/every-app-gateway/src/auth/sign-in-providers/providers.ts apps/every-app-gateway/src/auth/sign-in-providers/oidc-provider.ts apps/every-app-gateway/src/auth/sign-in-providers/membership.ts apps/every-app-gateway/src/auth/sign-in-providers/plugin.ts apps/every-app-gateway/src/auth/identity-membership.ts apps/every-app-gateway/src/auth/sign-in-methods.ts apps/every-app-gateway/src/auth/sign-in-providers/sign-in-providers.sqlite.test.ts docs/runbooks/sign-in-providers.md docs/security-model.md docs/threat-model.md

On this page