Cloudflare Access protects your domain, not your deployment
Our admin panel sat behind Cloudflare Access and redirected every request to a login. The same deployment, on the address Cloudflare gives every Pages project by default, served the full app to anyone who asked. An Access policy is bound to a hostname, and a deployment usually answers on more than one. Here is how to check yours in about a minute.
Contents
On 28 August 2026, checking our own admin panel before a deploy, we found it was publicly reachable. It had been behind Cloudflare Access the whole time and Access was working correctly. The panel was reachable anyway, at a second address we had not thought about.
The underlying behaviour is documented, is not a bug in Cloudflare, and catches people constantly. If you run anything on Cloudflare Pages behind Access, the check at the end of this post takes a minute and we would encourage you to run it.
What we saw
The panel lives at a custom domain. Request any path on it and you get bounced to Cloudflare Access to authenticate:
$ curl -sI https://admin.example.org/
HTTP/2 302
location: https://<team>.cloudflareaccess.com/cdn-cgi/access/login/...That is exactly right, and it is what we had tested. Every Pages project also gets a default address of its own, in the form <project>.pages.dev, and that address serves the same deployment:
$ curl -sI https://example-admin-prod.pages.dev/
HTTP/2 200
content-type: text/html; charset=utf-8Two hundred, with the full login page and the whole JavaScript bundle. No Access, no challenge, no interstitial.
Behind that login sat a console with read access to both of our products' admin APIs, an arbitrary read-only SQL runner and a database export. The only thing left in front of all of it was the application's own session cookie, which is precisely the thing Access exists so that you do not have to rely on.
Why it happens
An Access application is defined by the hostnames it covers. You create a policy, you attach it to admin.example.org, and Cloudflare enforces it on requests arriving for that name.
A Pages deployment is not a hostname. It is a thing that hostnames point at, and by default at least two of them do: the custom domain you configured, and the pages.dev address that came free with the project. Preview deployments add more. Attaching a policy to one name does not attach it to the others, because from Cloudflare's point of view you never asked it to.
Nothing here is broken. It is a mismatch between how the protection is scoped, which is by name, and how people think about it, which is "the admin panel is behind Access". The admin panel is not behind Access. One of its addresses is.
This generalises past Pages. Anywhere protection binds to a hostname while the thing being protected answers on several, the extra names are unprotected by default. Direct origin addresses behind a proxied domain are the same shape and a much older mistake.
How we fixed it, and why not in Cloudflare
The tidy fix is to add the second hostname to the Access application. We did not do that, or rather we did not do only that, for two reasons.
The first is practical. The Access configuration lives in a Cloudflare account whose API token, on the machine doing the audit, cannot read Access applications at all. The token reported zero applications while the redirect was demonstrably real. If your tooling cannot see a control, your tooling cannot tell you when the control goes away.
The second is the one that actually decided it. A rule that lives in a dashboard can be edited, forgotten, or simply never applied to next month's project. A rule that lives in the application ships with the application. So the real fix is a host allowlist in code, checked before anything else runs, that returns 404 for any hostname not on it:
// hooks.server.js, at the very top of the request path
const host = event.request.headers.get('host');
if (!isAllowedHost(host)) {
return new Response('Not found', { status: 404 });
}It returns 404 rather than 403 on purpose. A 403 confirms that something is there. The allowlist defaults to the production hostname plus localhost, and an empty or missing host fails closed.
Ten tests cover it, and they are worth listing because each one is a way we could have got the comparison wrong: suffix attacks (admin.example.org.evil.com), prefix attacks (evil-admin.example.org), case folding, hosts carrying a port, preview subdomains, and the empty host. They import the real module rather than a copy of the logic, which is the difference between testing the guard and testing a paraphrase of it.
What we did not fix
One thing is still reachable and we decided to accept it. Static assets are served by the Pages CDN before the application's code runs, so the hook never sees those requests. A build metadata file at /_app/version.json still returns 200 on the unprotected address.
We are comfortable with that because it contains a build timestamp and nothing else, the bundle directory is not listable, and the hashed asset filenames were only ever discoverable from the HTML that now 404s. We are stating it because a fix described without its remainder is a fix you cannot audit.
Check yours
For every Pages project you have behind Access:
# should redirect to Access
curl -sI https://your-custom-domain/ | head -1
# should NOT be a 200 with your app in it
curl -sI https://<project>.pages.dev/ | head -1
# check preview addresses too
curl -sI https://<hash>.<project>.pages.dev/ | head -1If the second or third command returns 200 and the body is your application, that application is public. Add the hostnames to the Access policy, and then add the allowlist in code as well, so the next project you create does not depend on you remembering this.
We found this on one project and immediately checked the other two we run. That is the actual lesson: this is not a bug you fix, it is a class you sweep. If you have one Pages project behind Access, you probably have several, and they were all created by the same habit.
Crossfyre maps every hostname an application answers on, which is how findings like this stop being luck. Start free