2026-09-02

Gate a self-hosted app with Cloudflare Access

No auth code, just a policy at the edge, and two quiet ways that gate can stop doing anything.

You've got something running on your own server. A dashboard, an admin panel, a tool you use once a week, something you spun up on Coolify or a plain Docker container and never got around to putting a login on. It works fine. It's also sitting on the open internet the moment DNS points at it, reachable by anyone who finds the hostname, guesses it, or has it turn up in a search result. Writing real auth for a small self-hosted tool is often the exact thing that stops it ever shipping. Cloudflare Access solves the specific case where the answer to "who should be able to open this" is a short, known list of people: you, maybe a couple of others by name. It puts a login in front of the app without you writing a session, a password hash, or a user table.

What Access actually does

Access sits at Cloudflare's edge, in front of the hostname, not inside your app. When a request comes in for a hostname you've put behind Access, Cloudflare checks whether the visitor already has a valid session for that hostname before the request goes anywhere near your server. No valid session, no request reaches your origin at all. The visitor gets Cloudflare's own login flow instead of your app, not a 404 and not whatever your app would otherwise return. Your app itself doesn't know Access exists. It never sees the unauthenticated traffic, never checks a header, never needs a login route of its own. All the identity logic lives one hop before your code runs at all.

Setting it up

The shape of it is simple. You create an Access application scoped to the one hostname fronting your tool (app.example.com), not the whole domain, so unrelated services on the same zone aren't touched. You attach a policy to it: an allow rule that names the people who should get through, by email address, and nobody else matches. For the identity check itself you don't need a full identity provider. One-time PIN over email is enough for a short named list: Cloudflare emails a code to the address, the visitor enters it, and that becomes the session. If you already run an identity provider for the people on the list, you can wire that in instead so the login matches what they use elsewhere, but OTP is the zero-setup option and is genuinely fine for you plus a handful of people you trust.

  1. Add the hostname to an Access application scoped to just that host, not the whole domain
  2. Attach a policy: action Allow, rule Include, with the specific email addresses that should get through
  3. Pick an identity method: one-time PIN needs no further setup, or point it at an identity provider you already run
  4. Save it, then test from a browser session or device that has never logged in, not your own already-authenticated one

Trap one: the gate only exists while the DNS record stays proxied

A DNS record on Cloudflare can be proxied (routed through Cloudflare's edge, the orange cloud) or DNS-only (Cloudflare just answers the query with your real IP and steps out of the way, the grey cloud). Access can only intercept a request that actually passes through the edge. Flip that record to DNS-only, for any reason at all, and the request path changes from browser to Cloudflare to Access check to origin, down to just browser to origin directly. The app keeps serving exactly as before. Nothing errors. There's no interstitial, because there's no edge left in the path to show one.

That's the failure mode worth building the whole post around, because it doesn't announce itself. The Access application is still sitting there in the dashboard, still configured, still showing green. The site still loads, correctly, for anyone who asks. It's just that the login it was supposed to require is gone, because the one condition that made it apply, the request actually routing through Cloudflare, stopped being true. A record can get flipped by someone troubleshooting a connectivity issue, by an old runbook step, by an automation that toggles proxy status for a reason that has nothing to do with this app. None of those look dangerous at the time.

Trap two: your origin is still there

Access gates the hostname. It does not firewall your server. Your app is still running on a machine with a real public IP, and unless you've separately done something about that, the IP answers directly, Cloudflare or no Cloudflare. Anyone who knows or finds the origin's real address, from an old DNS record, a screenshot, a shared box that hosts other things reachable by their own hostnames, or a plain port scan, can hit the app directly and skip Access entirely. No login prompt, no policy check, because none of that lives on the server.

This matters more the moment your app shares a server with other public sites, which is the normal shape for anyone running several apps off one VPS behind one reverse proxy. The origin address isn't a secret to begin with once even one of those other sites has ever been reached by IP during setup, debugging, or a certificate check. Access being perfectly configured on the hostname does nothing about someone going straight to the IP instead.

Keep it out of search

A gated app is still a URL. If nothing tells crawlers otherwise, a search engine will happily index the login interstitial, or cache whatever the page looked like before Access went on. Add noindex directly to the app: a meta tag if you control its HTML, a response header if you don't.

<meta name="robots" content="noindex, nofollow">

# or as a response header, e.g. from nginx in front of the app
add_header X-Robots-Tag "noindex, nofollow" always;

A robots.txt disallow is weaker than this and easy to reach for instead. Disallow only asks a well-behaved crawler not to fetch the page; it does nothing about a URL that has already been linked somewhere showing up in results with no snippet. noindex on the page or the header is what actually keeps a gated app out of search.

Verify the gate is actually on

Don't take the Access dashboard's word for it. Check from the outside, the way an anonymous visitor would: a private browser window with no session, or a plain request with no cookies attached.

curl -sI https://app.example.com/

A working gate answers with Cloudflare's own login flow, not your app: a redirect toward Cloudflare's Access login, or the login page itself, depending on how the request is made. If that request instead comes back with your app's actual response, a normal 200 with your app's usual headers, the gate isn't there, whatever the dashboard says. Run this same check again after any DNS change to the record, not only once at setup. That's the whole point of checking from the outside: the Access application will keep reporting healthy in the dashboard even while the DNS record has quietly stopped routing through it at all.

Topics

Still stuck? Send it to Emsden Studio and get a straight answer on what it is and what fixing it costs.

More guides

All guides