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:
{
"workers_dev": false,
"routes": [{ "pattern": "console.example.com", "custom_domain": true }]
}pnpm run deploy --profile prodThe 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:
{
"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/mcpanswer404. App routes,/v1/healthzand/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.
| Endpoint | Limit | Counted by |
|---|---|---|
POST /v1/auth/sign-in/email | 10 per minute | client IP |
POST /v1/auth/sign-in/email | 10 per 10 minutes | the email address in the body |
POST /v1/apps/{app}/auth/challenge | 60 per minute | app and client IP |
POST /v1/apps/{app}/auth/register | 60 per minute | app and client IP |
POST /v1/apps/{app}/auth/token | 60 per minute | app 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.
-
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" -
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)" -
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
matchesoperator 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.