AppAIGatewayDocs
Self-hosting

Custom domain and rate limiting

Serve the gateway on your own hostname, give your apps a separate API host, and see the limits that bound abuse.

A workers.dev address works, but it belongs to no zone of yours, so there is nowhere to attach a WAF rule. Serving the gateway on a domain you own in Cloudflare gives you one, and lets you refuse a flood before it ever starts a Worker. The gateway's own rate limits, further down, run either way. Everything on this page is zone configuration and a deployment profile; nothing here changes code.

Serve on a custom domain

Add the route to a deployment profile and turn the workers.dev address off:

wrangler.prod.overlay.jsonc
{
  "workers_dev": false,
  "routes": [{ "pattern": "console.example.com", "custom_domain": true }]
}
pnpm run deploy --profile prod

The domain must be a zone in the same Cloudflare account. With custom_domain: true, Cloudflare creates the DNS record and the certificate for you.

A separate host for your apps

One Worker can answer on two custom domains: the one you sign in to, and one your apps call. Add both routes to the overlay and name the second one in vars:

wrangler.prod.overlay.jsonc
{
  "workers_dev": false,
  "routes": [
    { "pattern": "console.example.com", "custom_domain": true },
    { "pattern": "api.example.com", "custom_domain": true }
  ],
  "vars": { "PUBLIC_API_URL": "https://api.example.com" }
}

PUBLIC_API_URL is an origin: https, no path, no query. A value with a path or a query is rejected rather than used, because it would silently produce base URLs no client can call. Setting it changes two things:

  • The console advertises the API host. Wherever the console shows a base URL, it prints https://api.example.com, so new apps are configured against the API host from the start.
  • The API host refuses the console surface. On api.example.com, /v1/auth/*, /v1/console/* and the MCP endpoint /mcp answer 404. App routes, /v1/healthz and /v1/admin/* are served normally. No session cookie can be issued for a host that has no sign-in, so a management call arriving there is a management-key call by construction.

The console host is unchanged and keeps serving the app routes too, so clients configured before the second host existed keep working. Leaving PUBLIC_API_URL unset is the default and changes nothing: the console builds client URLs from its own origin.

Keep the API host to the API

Static assets are served before the Worker runs, so the console's files still load at https://api.example.com/. An API host should answer nothing but the API. Refuse everything outside /v1/ with a zone WAF custom rule, action Block, matching:

(http.host eq "api.example.com" and not starts_with(http.request.uri.path, "/v1/"))

Unknown paths under /v1/ still reach the Worker and get its JSON 404. On the Free plan the blocked request receives Cloudflare's own 403 page. On Pro and above, give the Block action a custom JSON response with status 404 and the Worker's own body, so every miss on that host looks the same:

{"error":{"code":"invalid_request","message":"Route not found"}}

Do not reach for run_worker_first on the whole site instead. That would put every asset request through the Worker and bill for it.

Rate limiting

The gateway throttles its own authentication endpoints, on every deployment — including a one-click workers.dev install that has no zone to attach a rule to. The counters live in a Durable Object rather than in memory, so one count holds across every instance and location the Worker runs in.

EndpointLimitCounted by
POST /v1/auth/sign-in/email10 per minuteclient IP
POST /v1/auth/sign-in/email10 per 10 minutesthe email address in the body
POST /v1/apps/{app}/auth/challenge60 per minuteapp and client IP
POST /v1/apps/{app}/auth/register60 per minuteapp and client IP
POST /v1/apps/{app}/auth/token60 per minuteapp and client IP

Sign-in is counted twice because the two counts refuse different things. The count per address stops one client hammering the endpoint. The count per email address stops a spread of addresses working through one password, which no per-address count can see. Ten attempts per ten minutes is far more than you spend when you know your own password, and nowhere near enough to guess one.

The three application routes are unauthenticated by design, so each is counted per app and per address, on a counter of its own. Sixty a minute is far above real use — a gateway token lasts an hour, so sixty exchanges a minute from one address speaks for thousands of devices — and still bounds a flood.

Every refusal answers 429 with the code rate_limited, a Retry-After header, and a body naming the limit, its window, and when it reopens.

Only CF-Connecting-IP is read. Cloudflare writes it on every inbound request, so a caller cannot choose its own counter; X-Forwarded-For can be set by anyone and is ignored. A Worker reached directly, with no Cloudflare in front of it, sees no such header and counts every caller as one.

Optional: refuse at the edge as well

Nothing in this section is required, and skipping it leaves the limits above in force. What it adds is Cloudflare rate limiting rules in front of the Worker: a refused request never starts one, so it costs you no Worker invocation at all. Rules need a zone, which means the custom domain set up above.

Create three rules, each counting by client IP.

  1. Console sign-in, 10 requests per minute, block for 10 minutes. This is the password endpoint and the one route where guessing pays.

    http.request.uri.path eq "/v1/auth/sign-in/email"
  2. Account creation and password reset, 5 requests per minute. These are the routes the gateway does not count for itself.

    http.request.uri.path matches "^/v1/auth/(sign-up|forget-password|reset-password)"
  3. App authentication, 60 requests per minute, which mirrors the gateway's own count for these routes at the edge.

    http.request.uri.path matches "^/v1/apps/[^/]+/auth/(challenge|token|register)$"

The rules match on the path alone, so they cover every host in the zone. A separate API host is included with no second set of rules.

On the Free and Pro plans

Rate limiting rules exist on every Cloudflare plan, but not in equal amounts. If you add them at all, adapt rather than skip.

  • The Free plan allows one rule, with a fixed ten-second counting period and a ten-second block. Spend it on sign-in, at 2 requests per ten seconds.

  • The matches operator needs a Business plan or above. On Free and Pro, write rules 2 and 3 without a regular expression:

    http.request.uri.path in {"/v1/auth/sign-up/email" "/v1/auth/forget-password" "/v1/auth/reset-password"}
    starts_with(http.request.uri.path, "/v1/apps/") and (ends_with(http.request.uri.path, "/auth/challenge") or ends_with(http.request.uri.path, "/auth/token") or ends_with(http.request.uri.path, "/auth/register"))

Checklist

After the profile is deployed and the zone is configured:

curl https://api.example.com/v1/healthz
curl -i https://api.example.com/v1/auth/sign-in/email
curl -i https://api.example.com/

The first answers "vault": "ok". The second answers 404 from the Worker. The third is blocked by the WAF rule. Re-check the rules after any zone change; they live in the dashboard, not in the repository.

On this page