Configuration
Configure a self-hosted gateway, including secrets, sign-in, hostnames and maintenance.
Hosted users manage their apps and providers in the console; none of these settings are needed. This page configures a gateway you run yourself.
A self-hosted gateway is configured through Worker variables and secrets. A variable is plain text you can read back in the dashboard. A secret is write-only: once set, only its name is visible. Provider keys are not configured here at all. You add them in the console, and the gateway encrypts them with the vault described below.
Where a setting lives
| Place | What it holds | When you use it |
|---|---|---|
| The Deploy to Cloudflare form | Every secret name in .dev.vars.example, masked, and every entry in the vars block of wrangler.jsonc, in clear text with its default. | The one-click deployment. |
| The Worker's Settings → Variables in the Cloudflare dashboard | Any variable or secret, after deployment. | Changing a setting on a deployed gateway without a redeploy from a checkout. |
.dev.vars in your checkout | Secrets. pnpm run secrets:upload sends them to the Worker; pnpm run dev reads them locally. | Wrangler deployments and local development. |
The vars block of a profile overlay | Plain variables for one deployment. | Anything a deployment profile changes. |
One rule keeps those places consistent: a setting is listed in exactly one of
.dev.vars.example, where it is a secret, or the vars block of
wrangler.jsonc, where it is a plain value. The deploy form masks every name
from the first file and prints every value from the second, so a setting that
appears in both is asked for twice, once behind asterisks.
Set a secret with Wrangler's hidden prompt or from stdin, never as a command argument:
pnpm exec wrangler secret put SECRET_VAULT_LOCAL_KEK_V2Wrangler and button deployments can overwrite dashboard edits to plain
variables. Keep persistent changes in the configuration used to deploy, or
configure Wrangler's keep_vars.
CLI updates already use --keep-vars to preserve additional dashboard variables;
values explicitly supplied by the deployment configuration still take precedence.
Secrets are managed separately from plain variables.
The vault
Provider keys are encrypted at rest. SECRET_VAULT_MODE chooses how, and the
choice is explicit: nothing is inferred from which secrets happen to exist, and
a missing or mixed configuration is a hard error rather than a quiet fallback.
SECRET_VAULT_MODE | Requires | Use it when |
|---|---|---|
local (default) | SECRET_VAULT_LOCAL_KEK_V1; optionally SECRET_VAULT_LOCAL_KEK_V<n> and SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION | Self-hosting. AES-256-GCM inside the Worker under a key you hold. No second service. |
kms | SECRET_VAULT_KMS_URL, SECRET_VAULT_KMS_TOKEN | Hardened deployments. Envelope encryption through a cf-kms Worker in a separate Cloudflare account, so the root key never enters this account and every decrypt is audited and revocable. |
SECRET_VAULT_LOCAL_KEK_V1 is a secret. Make one with:
openssl rand -base64 32SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION is a plain variable naming which
SECRET_VAULT_LOCAL_KEK_V<n> new writes use. Older versions stay accepted for
reading, which is what makes rotation possible; see
Operations.
A variable from the other mode is an error, not something to ignore:
SECRET_VAULT_LOCAL_KEK_V1 present in kms mode, or SECRET_VAULT_KMS_URL
present in local mode, marks the vault as misconfigured. GET /v1/healthz
reports the result in a non-secret vault field, "ok" or "misconfigured",
and the Worker log names the offending variable without printing its value.
Running kms mode
Deploy a cf-kms instance dedicated to this gateway in your security account,
on a custom domain, with its own KEK_V1 and a CALLERS entry of:
{ "name": "app-ai-gateway", "requiredContext": { "service": "app-ai-gateway" } }Then set SECRET_VAULT_MODE=kms on this Worker, remove
SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION, and add the URL and the caller token
as secrets. Encrypted blobs are self-describing by prefix, so switching modes
on a gateway that already holds provider keys is a decrypt-and-re-encrypt
migration, and a mismatch fails loudly rather than silently.
Back the vault key up
In local mode, losing every SECRET_VAULT_LOCAL_KEK_V<n> makes the stored
provider keys permanently unreadable. Keep a copy outside Cloudflare.
Generated secrets
| Name | Purpose |
|---|---|
JWT_SECRET | Signs the short-lived tokens the gateway issues to your apps. |
BETTER_AUTH_SECRET | Signs console sessions and management keys. |
Both are created on the first deployment, by the deploy script or by the
one-click flow, and kept unchanged afterwards. For local development,
pnpm run secrets:setup-local generates them into .dev.vars. You never
supply a value for either.
Sign-in
ALLOWED_REGISTRATION_EMAILS
Plain variable, empty by default. The email addresses that may create an
account in the console, separated by commas and matched without regard to
case. Each listed person who registers gets an account of their own, with no
access to anyone else's. Nobody else can register, and an empty list means
nobody can. * lets anyone register.
The list only decides who may register. Removing an address does not remove that person or stop them signing in.
It does not apply to claiming the account agw deployment setup creates.
Only the CLI holding that account's key can start a claim, so agw account claim works with any email.
CLI_BOOTSTRAP_TOKEN_DIGEST
Secret, set by agw deployment setup when it deploys the Worker. It is the
SHA-256 of the token the CLI then creates its account with, so that account is
the CLI's however soon anyone else finds the address. Without it, nothing can
create an account without signing in. You never set it yourself.
DEPLOYMENT_ID
An immutable public UUID stored as a plain Worker binding. The deployment helpers generate it automatically and reuse the deployed value on updates and domain changes. The CLI compares this identity with Cloudflare settings before adopting an existing Worker. Do not upload it as a secret.
You normally do not need to set it. An explicit value in the build environment
or Wrangler vars is supported for initial setup, but must match the existing
identity on updates, and must be 8 to 88 letters, digits, underscores or
hyphens. Local setup stores its development value in ignored
.dev.vars; pnpm run deploy uses that value only when no deployed or explicitly
configured identity exists.
CLI_CONSOLE_ORIGIN
Optional secure first-party origin for browser handoffs. It must serve the same
Worker: both the console, which renders the approval page the CLI links to, and
the /v1/cli/browser/ routes that page calls. HTTPS is required outside local
development. Leave it unset when the API and console share an origin, unless
you want MCP clients to connect with OAuth: that needs it set to the console's
one canonical address; see MCP clients.
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
Secrets. Both must be set for the console to offer sign-in with Google; with either missing the button does not appear. Create an OAuth client in Google Cloud with the authorised redirect URI:
https://YOUR-HOST/v1/auth/callback/googleGoogle follows the same registration rule as email and password: only a listed email can create an account. A pending claim can create the person who owns its existing account through the scoped Google flow, with any email.
Signing in with Google never joins a sign-in that already exists for the same email address. If you registered that address with a password, keep using the password; Google sign-in is refused for it rather than opening the same account.
OAUTH_RELAY_URL
Plain variable, unset by default. It points at an OAuth callback relay for local instances whose hostname changes, so it cannot be registered with Google. A deployment that owns a stable hostname leaves it unset. See Local development.
Email verification
Unset by default, and then nothing is verified: every sign-in is trusted with
the address it gave, and ALLOWED_REGISTRATION_EMAILS is the only check on
who registers. Anyone who knows a listed address could register it before its
owner does. They would get an account of their own, with no access to anyone
else's, and the owner could not register that address until the sign-in was
removed.
To verify addresses, give the gateway a way to send email: an SMTP server from any mail provider, or Cloudflare Email Service. Configuring either one turns verification on:
- A sign-up with a password is sent a link that verifies its address. It lasts 24 hours, and the console offers to send a new one. A Google sign-in is verified by Google and needs none.
- Until the owner of an account has verified their address, its apps answer
every request with
email_unverified. Everything else works, so apps, providers and keys can all be set up first. - Claiming the account
agw deployment setupcreates waits for a verified address too: the approval page asks for it after you create your sign-in. - Accounts that existed before you turned verification on are unaffected, and keep serving requests whoever owns them.
Both ways need a domain you own, with the records your mail provider asks for
(SPF and DKIM), or verification emails land in spam. A workers.dev address
cannot send email.
SMTP: SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD
Works on the Workers Free plan, with any provider that offers SMTP sending.
Set three plain variables and one secret, plus EMAIL_FROM. Setting any one of
them counts as choosing SMTP, so one left out is reported rather than ignored:
| Name | Kind | Value |
|---|---|---|
SMTP_HOST | Variable | Your provider's SMTP server, for example smtp.example.com. |
SMTP_PORT | Variable | 465 (the default) for TLS from the start, or the port your provider gives for STARTTLS, usually 587. Port 25 is not available to Workers. |
SMTP_USERNAME | Variable | The account the gateway signs in as, usually the sending address. |
SMTP_PASSWORD | Secret | Its password, or an app password where your provider has them. |
EMAIL_FROM | Variable | The address emails come from. Bounces come back to it, so use a mailbox you check. |
EMAIL_REPLY_TO | Variable | Optional. Where replies go instead, such as a support inbox. |
pnpm exec wrangler secret put SMTP_PASSWORDThe gateway only signs in over an encrypted connection: on a port other than 465 it refuses to send the password if the server does not offer STARTTLS.
Cloudflare Email Service: EMAIL
Needs the Workers Paid plan to send to anyone but yourself.
-
Onboard your domain to Email Sending, in the dashboard or with
pnpm exec wrangler email sending enable example.com. -
Add a
send_emailbinding namedEMAILand setEMAIL_FROMto an address on that domain, in your profile overlay:{ "send_email": [{ "name": "EMAIL" }], "vars": { "EMAIL_FROM": "gateway@example.com" } }
EMAIL_REPLY_TO
Plain variable, unset by default, for either way of sending. Set it to send
replies somewhere other than EMAIL_FROM: a dedicated sending mailbox for
EMAIL_FROM, whose password the gateway holds, and your support inbox here.
Bounces still go to EMAIL_FROM.
EMAIL_SENDER
Plain variable, unset by default. With only one way to send configured, the
gateway uses it. With both, set EMAIL_SENDER to smtp or cloudflare to
choose; switching is changing this one value.
/v1/healthz reports "email": "ok" once the chosen way is ready to send,
"off" with none configured, and "misconfigured" when one is configured but
cannot send yet: a setting missing, EMAIL_FROM not an address, or both ways
configured without EMAIL_SENDER. Verification stays on while it is
misconfigured, and the Worker's logs name the setting to fix when a link
cannot be sent.
Hosts
PUBLIC_API_URL
Plain variable, unset by default. Set it when the gateway answers on a second
custom domain that your apps call, separate from the one you sign in to. It
must be an origin: https, no path, no query. It changes what the console
prints as the base URL and which routes answer on that host; see
Custom domain and rate limiting.
MCP clients
The MCP server accepts a management key without extra
configuration. OAuth through the console's consent page also requires a stable
deployment ID and console origin. The deployment helpers generate
DEPLOYMENT_ID automatically; CLI deployment setup also
configures the console origin for you.
For Wrangler or button deployments, set
CLI_CONSOLE_ORIGIN to the one canonical address of
your console — https, or for local development http on a loopback host:
localhost, 127.0.0.1, [::1] or any name under .localhost, such as
http://gateway.localhost:5173. That address is the authorization
server every token is issued by and bound to, so it has to be one fixed origin:
if the gateway took it from each request, a deployment answering on two names
would issue tokens for both, and the nightly cleanup of ended connections,
which has no request, would have none. Without either setting, OAuth is off:
the /.well-known and /oauth addresses answer 404, access tokens are
refused, and clients connect with management keys only. A plain http address
on any other host is not a console origin the gateway accepts at all, so it is
refused rather than leaving OAuth off; see
CLI_CONSOLE_ORIGIN.
MCP_ALLOWED_ORIGINS
Plain variable, unset by default. The browser origins, besides the console's
own, that may call the MCP server at /mcp and its OAuth
endpoints under /oauth, comma-separated:
{
"vars": { "MCP_ALLOWED_ORIGINS": "https://inspector.example.com,https://agent.example.org" }
}Each entry is an exact origin: https (or http on a loopback host such as
localhost, for local development), host and port, no path. An entry that is
not one is ignored and logged as a warning, and the others still apply. A listed origin receives
CORS headers and its preflight is answered; a request from any other web page
is refused with 403. MCP clients that are not browsers send no origin and
are served whatever this says, so most deployments leave it unset. On a
deployment reachable through more than one host name, also set
CLI_CONSOLE_ORIGIN: without it, every host the Worker
answers on counts as the console's, and so does each host's own origin.
OAUTH_CIMD
Plain variable, unset by default, which leaves it on. An MCP client connecting
with OAuth identifies itself with an https URL, the address of a Client ID
Metadata Document describing it; the gateway fetches that document when the
client asks to connect, within fixed bounds: https only, no address or
localhost hosts, no redirects, five seconds and 64 KiB. Set it to false to
refuse every client that identifies itself this way, so only the clients in
OAUTH_CLIENTS can connect:
{
"vars": { "OAUTH_CIMD": "false" }
}The authorization server's metadata says which: its
client_id_metadata_document_supported follows this variable.
OAUTH_CLIENTS
Plain variable, unset by default. An advanced option: OAuth clients you register
yourself, for a client that cannot publish a metadata document, as a JSON array
of clientId, name and redirectUris:
{
"vars": {
"OAUTH_CLIENTS": "[{\"clientId\":\"my-tool\",\"name\":\"My tool\",\"redirectUris\":[\"http://127.0.0.1/callback\"]}]"
}
}A registered client is a public client, with no secret. Its name is shown on
the consent page as yours rather than the client's, so the page does not name a
domain for it. Each redirect URI must be absolute, with no fragment: https,
http on a host written exactly 127.0.0.1, [::1] or localhost (any port
then matches), or an app's own scheme such as com.example.app:/callback. Any
other address must match character for character. An entry that breaks a rule
is ignored and logged as a warning, and the others still apply. A registered
clientId wins over a metadata document of the same address.
Providers
PROVIDER_TTFB_TIMEOUT_SECONDS
Plain variable, default 120. How long a provider may take to send its
response headers before the gateway gives up and answers 504. Once headers
arrive the body streams for as long as the model needs; only the first byte is
bounded. Reasoning models can think for a minute before that first byte, which
is why the default is generous. A custom endpoint's fallback chain shares one
budget across all of its targets, so the wait for response headers cannot exceed this across the chain. A primary
that never answers consumes the whole budget and leaves no time for a fallback.
Lowering the value shortens that wait; it does not reserve time for fallbacks.
Nightly maintenance
MAINTENANCE_QUERY_BUDGET
Plain variable, default 50. How many D1 queries the nightly maintenance cron
may issue in one run: it prunes sign-in history and spent challenges, expires
finished CLI authorizations, and compacts usage history into its rollups.
Every D1 query is a subrequest, and a Worker invocation may issue 50 of them on the Cloudflare Free plan and 1,000 on the Workers Paid plan. The default is the Free number, so a gateway installed with one click stays inside it without being asked anything. The run stops at the budget and continues the next night, which is why exceeding it is never necessary: nothing is lost, only deferred.
Raise it only if you are on the Workers Paid plan and have a backlog worth
draining faster — after a long period of heavy traffic, or after restoring an
old database. Values below 20 are ignored, and so is anything that is not a
whole number. Set it in the dashboard under Settings → Variables, or in a
profile overlay; it is deliberately not part of the one-click form.
Legal links
TERMS_OF_SERVICE_URL and PRIVACY_POLICY_URL
Plain variables, unset by default. When both are set, the sign-up screen shows a consent line linking to them. With either missing, nothing is shown. Set them in a profile overlay or in the dashboard.
Management analytics
MANAGEMENT_ANALYTICS
Analytics Engine dataset binding, unset by default. With it bound, the gateway writes one data point for every management call — from the console, the API, the CLI or an MCP client — so you can see which of those your accounts use, and how often each is refused or fails. Unbound, nothing is recorded and nothing extra runs.
Each point carries the account id, the channel, the credential type, the
operation or MCP tool, how the call ended (its HTTP status, error code and, for
an invalid request, the top-level field the schema refused), which known client
called, such as agw, curl or claude-code (other for any client not on
the gateway's list), the client's version, how long the call took, and
Cloudflare's request id. It never carries an error message, a request value, a
credential or an email address, and a client's name is never copied as sent.
Calls your apps make through the gateway are not recorded.
A call refused before sign-in still names the operation it asked for, and its
reason says why: credential_missing (nothing sent), credential_malformed
(something that cannot be a management key or token), console_header_missing
(a console session cookie sent without the console), credential_rejected (a
well-formed key or token that is expired or revoked) or session_expired.
The first three are usually a client's mistake; the last two a credential that
went stale. The client is answered 401 auth_required whichever it is. Every
refusal and failure is also logged in the Worker's logs with the same request
id, as ray, so a refused call you find in the dataset leads to its log line.
Add the binding in your profile overlay:
{
"analytics_engine_datasets": [
{ "binding": "MANAGEMENT_ANALYTICS", "dataset": "agw_management" }
]
}Query it with the SQL API. The blobs are, in order: channel, operation, tool, kind, outcome, code, reason, field, issue, credential, identity, client, client version, request id. The doubles are the HTTP status and the duration in milliseconds, and the index is the account id. For example, the refusal rate per channel over the last week:
SELECT blob1 AS channel,
SUM(_sample_interval) AS calls,
SUM(IF(blob5 = 'refused', _sample_interval, 0)) AS refused
FROM agw_management
WHERE timestamp > NOW() - INTERVAL '7' DAY
GROUP BY channelSign-up analytics
Optional and unset by default. Bind a Worker of your own as ANALYTICS to be
told each time an account is created, for your own sign-up reporting. With
nothing bound, the gateway reads nothing for it and sends nothing.
{
"services": [
{ "binding": "ANALYTICS", "service": "your-analytics-worker", "entrypoint": "AnalyticsHook" }
]
}Your Worker exports that entrypoint with one method, accountCreated(event).
The event below is everything it receives, defined in src/analytics/hook.ts.
It never includes a key, a password, an email address, a name or the request.
| Field | What it is |
|---|---|
accountId | The new account's random ID. |
userId | The random ID of the person who created it. |
kind | created, or claimed when a person takes over an account an agent set up. |
occurredAt | When it happened. |
country | The two-letter country Cloudflare placed the sign-up in, or null. |
firstTouch | Where the visitor first came from, or null. |
firstTouch comes from a first_touch cookie that your own website sets on a
first visit, on a parent domain the console shares. The gateway reads only that
cookie, only when an account is created and never when the browser sends Do
Not Track (which also leaves country empty), and keeps only these fields, each checked and length-limited: referrerHost, landingPath, utmSource,
utmMedium, utmCampaign, firstSeenAt and visitorId.
The call runs after the account is saved. Its result is ignored and a failure is only logged, so the bound Worker cannot slow down or change a sign-up.
Summary
| Name | Kind | Default |
|---|---|---|
SECRET_VAULT_MODE | Variable | local |
SECRET_VAULT_LOCAL_KEK_V1, SECRET_VAULT_LOCAL_KEK_V<n> | Secret | Required in local mode |
SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION | Variable | 1 |
SECRET_VAULT_KMS_URL, SECRET_VAULT_KMS_TOKEN | Secret | Required in kms mode |
JWT_SECRET, BETTER_AUTH_SECRET | Secret | Generated |
ALLOWED_REGISTRATION_EMAILS | Variable | Empty |
CLI_BOOTSTRAP_TOKEN_DIGEST | Secret | Set by agw deployment setup |
DEPLOYMENT_ID | Variable | Automatically generated and preserved on updates |
CLI_CONSOLE_ORIGIN | Variable | Current origin |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Secret | Unset |
OAUTH_RELAY_URL | Variable | Unset |
SMTP_HOST, SMTP_PORT, SMTP_USERNAME | Variable | Unset (no verification); port 465 |
SMTP_PASSWORD | Secret | Unset; required with SMTP_HOST |
EMAIL | send_email binding | Unset (no verification) |
EMAIL_SENDER | Variable | Unset; required when both are configured |
EMAIL_FROM | Variable | Unset; required to send |
EMAIL_REPLY_TO | Variable | Unset (replies go to EMAIL_FROM) |
PUBLIC_API_URL | Variable | Unset |
MCP_ALLOWED_ORIGINS | Variable | Unset |
OAUTH_CIMD | Variable | Unset (on) |
OAUTH_CLIENTS | Variable | Unset |
PROVIDER_TTFB_TIMEOUT_SECONDS | Variable | 120 |
MAINTENANCE_QUERY_BUDGET | Variable | 50 |
TERMS_OF_SERVICE_URL, PRIVACY_POLICY_URL | Variable | Unset |
MANAGEMENT_ANALYTICS | Analytics Engine binding | Unset (nothing recorded) |
ANALYTICS | Service binding | Unset |