How Cloudflare Access Works: Admin Protected Without Writing a Login Page
Cloudflare Access puts an identity check in front of a hostname, at Cloudflare’s edge, before the request ever touches your server. Whoever opens admin.yourdomain.com meets a login backed by an identity provider (Google, GitHub, corporate SSO), the service that already holds your team’s accounts. The alternative is a one-time PIN by email. The application behind it has no login page, no user database, and no password to leak. After authentication, every request reaches the application carrying a JWT, a token signed by Cloudflare, in the Cf-Access-Jwt-Assertion header. The application validates that token and knows, with a cryptographic signature, who is on the other side. It is login, 2FA and SSO as an edge service. The half almost no tutorial tells you is that the application must validate the token. Without that, the protection is smaller than it looks.
This post is the natural sequel to the anatomy of a single VPS serving everything through Cloudflare Tunnel with zero open ports. There the subject was how traffic gets in; here, how that same box’s admin surfaces authenticate. The raw material is the house’s two real uses. The first is n8n, a third-party app, at n8n.haruo.dev. The second is the operations panel of the multi-tenant agent service, an in-house app at painel.haruo.dev that decided to have no login of its own. Naming and behavior checked against the official documentation on 2026-08-25.
What does Cloudflare Access solve?
Every admin surface exposed to the internet needs the same list: well-stored passwords, 2FA, sessions, account recovery, brute-force protection. Writing all of that for an internal panel is cost without differentiation. For a third-party app, like an n8n or a Grafana, it is not even an option: you cannot graft your SSO onto someone else’s login.
Access moves the whole list to the edge. The question “who are you?” is answered before the request reaches the tunnel, using the identity provider you already have. The application behind receives the answer ready-made, and signed. The house’s two uses illustrate the two cases:
- Third-party app (n8n): Access sits in front of the hostname and that is it. n8n’s own login still exists underneath, but nobody reaches that screen without first proving identity at the edge. Two layers, zero code.
- In-house app (operations panel): the design went further. The panel has no login system: no password, no session cookie of its own, no users table. The edge identity is the authentication, and the service validates the token on every request.
The gain, stated modestly: this reduces the attack surface. The login page, credential stuffing (mass testing of leaked passwords) and forgot-my-password stop being your problem. It makes nothing invulnerable. The guarantee depends on two disciplines, the subject of the next sections.
How does Cloudflare Access work, step by step?
The whole mechanism, in the order a request traverses it:
- The hostname enters through Cloudflare’s edge. In the house’s design, this happens via Cloudflare Tunnel: the origin has no open port, and the connector opens the connection from the inside out.
- An Access Application (self-hosted type) is declared for the hostname in the Zero Trust dashboard, the Cloudflare area where no request is trusted by default. It is what says “this address requires identity”. It also carries the Application Audience (AUD) Tag, the identifier that later shows up inside the token.
- Policies decide who gets in. A policy has an action (Allow, Block, Bypass, Service Auth) and rules that combine criteria. Include works as OR, Require as AND, Exclude as NOT (policies documentation, checked 2026-08-25). The typical small-team admin case: one Allow policy whose Include is the list of authorized emails.
- Login happens at the edge. It can be an identity provider configured on your team (Google, GitHub, corporate SAML/OIDC) or a one-time PIN by email. The PIN gives you real 2FA with no IdP configured at all.
- The edge issues a JWT signed with RS256 for the team (
<team>.cloudflareaccess.com). In the browser it becomes theCF_Authorizationcookie. On every request forwarded to the origin it travels in theCf-Access-Jwt-Assertionheader. - The application validates the token. The public keys live in the team’s JWKS, the address that publishes those keys:
https://<team>.cloudflareaccess.com/cdn-cgi/access/certs. The app checks the signature,aud(the application’s AUD Tag),iss(https://<team>.cloudflareaccess.com) and validity (validation guide, checked 2026-08-25).
Steps 1 through 5 are configuration: half an hour of dashboard, not one line of code. Step 6 is the only one that is yours, and it is what separates a robust design from theater.
The common mistake: why validate the JWT in the application?
The temptation is to stop at step 5: “if the request got this far, Access let it through”. That reasoning trusts routing, and routing is not cryptography. It breaks along two concrete paths:
- The origin may be reachable by another route. Another container on the same internal network, a port published by mistake in a redeploy, an ingress rule left behind. Any request that reaches the app’s port without passing through the edge arrives with no Access at all. An app that does not validate the token serves that request as if it were legitimate.
- The convenience header is not signed. Alongside the JWT, Access sends a readable header with the authenticated email (
Cf-Access-Authenticated-User-Email). It is comfortable, and it is just text: whoever reaches the port directly writes whatever email they want into it. Cloudflare’s own documentation warns: validate the token with the public key, to ensure the request came from Access and not from a malicious third party.
The pattern the house’s panel follows, and that holds for any stack:
- Verify the signature against the team’s JWKS (RS256), and check
aud,issand expiry. Decoding the payload is not enough. - Take the identity from inside the signed token (the
emailclaim), never from the convenience header. An unsigned header is decoration. - Fetch the keys from the endpoint, with a cache and rotation tolerance. The documentation recommends not hard-coding the key, because Cloudflare rotates its keys. The right design is a short-lived cache plus a forced refresh when an unknown
kidshows up, the identifier of the key that signed the token. Key rotation must not turn into a 403 for everyone until someone restarts the service. - No new dependency. Verifying one RS256 token fits in ~40 lines on top of the cryptography library your service probably already has. A whole JWT dependency for a single
verify()is a maintenance tax.
The converse is worth saying too: validating the JWT does not replace closing the direct route. The two disciplines add up: the internal network as the boundary, and the token as proof that the request crossed the authenticated edge.
Why does failing closed matter?
Step 6’s validation depends on configuration: the team domain and the AUD Tag. And every configuration is missing someday: on the first deploy, in a restore, in a new environment. What the application does at that moment defines the design’s real security:
- The house’s panel answers 503 with instructions when the Access variables are absent. The message says what to configure and where. It never opens “temporarily without auth”. An admin that degrades to open is worse than an admin that is down, because down gets noticed.
- The development shortcut is refused in production. A dev mode exists that skips Access for local runs. The service refuses it when it detects it is running on the production database. Shortcuts that should only exist in dev have a habit of surviving a restore.
- The panel’s hostname answers only the panel’s routes. The same process serves webhooks and internal endpoints through other paths. A host guard ensures that, arriving through the public hostname, only
/panel*routes and the healthcheck exist. Access Application at the edge and a host guard at the origin: layers, not alternatives.
The principle that sums up all three points: an admin’s degraded state is off, never open.
Authentication is not authorization
Passing Access proves who you are. It does not prove you have anything to see inside. In the house’s multi-client panel, the authenticated email (read from the signed token) is mapped to a role in a versioned data file. An operator sees every client, a client sees only their own. An email that passed the edge but is not in the map gets a 403 explaining where access is granted. A missing file means nobody gets in: the same fail-closed principle, at the authorization layer.
The boundary is clean: Cloudflare answers “who this is”, the application answers “what they may see”. Mixing the two, using the Access policy as fine-grained authorization, works until the first per-resource exception. Then the policy becomes a tangle nobody audits.
When should you NOT use Cloudflare Access?
Access is a tool for admins and teams, not for products. The limits the house’s design respects:
- Your product’s customer login: no. Access authenticates members of your Zero Trust team, with the team’s login experience. A SaaS product’s users need product authentication (your own or a customer-identity provider), with the product’s flow, brand and scale.
- Machine-to-machine API calls: usually no. Service Auth with service tokens exists, credentials for programs rather than people. But if the consumer is a third party’s program, your own API key scheme is simpler and does not couple your customers to your edge provider.
- Fine-grained authorization as the central problem: Access does not solve it. It decides per application, not per record. If your question is “who may edit this resource”, you will write authorization anyway. Access only takes the login off your desk.
- Without the closed-origin discipline, the gain shrinks. Say the app stays directly reachable (open port, exposed origin IP) and does not validate the token. Access becomes a lock on the front door of a house with no back wall. In that scenario, fix the exposure first.
And one trade-off said out loud: your admin’s front door comes to depend on one vendor. For the house’s operation, which already delivers its traffic through that same edge, the marginal coupling is zero. For anyone not in that design, it is a decision to make with open eyes.
What does Cloudflare Access cost?
Public pricing, checked on 2026-08-25 on the product page: the Zero Trust free plan covers up to 50 users for $0. The paid pay-as-you-go plan costs US$ 7 per user/month billed annually. This post’s case is a handful of people behind two admin hostnames. For that, the free plan covers it comfortably, and it is what the house uses.
The summary you take home
- Access = login, 2FA and SSO at the edge: an Access Application in front of the hostname, Allow/Include policies deciding who gets in, and no login page to write. It works the same for third-party and in-house apps.
- The artifact that reaches your app is a signed JWT in the
Cf-Access-Jwt-Assertionheader, with public keys in the team’s JWKS andaud,issandexpclaims to check. - Validate the token; do not trust routing. The origin may be reachable by another route, and the email header is not signed. Identity comes from inside the JWT, always.
- Fetch keys from the endpoint, with a cache and a refresh on an unknown
kid. Key rotation must not lock everyone out. - Fail closed: without Access configuration, the admin answers 503 with instructions and never opens without auth. Dev shortcuts are refused in production.
- Authentication is not authorization: the edge proves who; the app decides who sees what, in a data map that, when absent, denies everything.
- It is not for product logins nor for fine-grained authorization. It is for your team to reach your admins. Up to 50 users, the free plan covers it (price as of 2026-08-25).
Infrastructure that scales without breaking the bank
Cloud bill out of control? I run my own on a single VPS with no open ports, automatic deploys and healthcheck-gated rollback. The whole design is published here.
Read the infrastructure posts →