For decades, internal tools were protected by being reachable only from the office network or the VPN. Anyone inside the network could reach the admin panel, the Grafana dashboard and the staging API, and anyone outside could not. That model fails in two ways: a single compromised laptop on the VPN can reach everything, and contractors, mobile staff and automated jobs all need awkward exceptions. Cloudflare Access replaces the network check with a per-request identity check at Cloudflare's edge. Every request to a protected hostname must carry proof that a specific person or service passed a policy for that application.
This article explains how Access works on the wire, how to design policies that stay correct as an organisation grows, how the origin must verify the token Access issues (the step most deployments get wrong), how machines and SSH fit in, and where the model breaks. The surrounding zero-trust concepts are covered in zero trust in the cloud, and the outbound connector that usually sits behind Access is covered in Cloudflare Tunnels. Product menus and plan limits change often, so this page sticks to the mechanisms in Cloudflare's developer documentation and avoids quoting console paths or limits that may move.
How a request flows through Access
Access works as a reverse proxy decision point. You register an application, usually a hostname and optional path, and attach policies. When a request for that hostname reaches Cloudflare's edge, Access looks for a valid application token in the CF_Authorization cookie.
- With no valid token, the edge redirects the browser to your team domain,
<team>.cloudflareaccess.com. - The team domain sends the user to the configured identity provider, such as an OIDC or SAML provider, or a one-time PIN sent by email.
- After login, Access evaluates the application's policies against the identity, including groups and any device or network signals you configured.
- If a policy allows the request, Access sets an application-scoped
CF_Authorizationcookie and redirects back to the original URL. - Subsequent requests carry the cookie. The edge validates it and forwards the request to the origin with a signed JSON Web Token in the
Cf-Access-Jwt-Assertionheader. - The origin validates that JWT, then serves the response.
The token's lifetime is governed by the application's session duration. When it expires, the user goes back through the team domain. If they still have a live identity-provider session, that is usually a silent redirect. Two endpoints are useful while debugging: /cdn-cgi/access/get-identity on the application hostname returns the identity Access holds for the current session, and /cdn-cgi/access/logout ends it.
Policies: actions, rules and logic
A policy has an action and a set of rules. The actions are:
| Action | Meaning | Typical use |
|---|---|---|
| Allow | A person who matches may proceed after login | Staff access to internal tools |
| Block | A matching request is denied | Explicitly deny a group or region |
| Bypass | Access is skipped for matching requests; no identity is enforced | Public health checks, static assets, webhooks you verify another way |
| Service Auth | Accepts non-identity credentials such as service tokens, without a login redirect | Machine-to-machine calls |
Rules come in three kinds, and the logic is the part people misread. Include rules are combined with OR: matching any one is enough to be considered. Require rules are combined with AND: every one must also match. Exclude rules are NOT: matching any one removes the request from the policy. Selectors include email addresses, email domains, identity-provider groups, country, IP ranges, service tokens and, with the WARP client, device posture checks.
A worked policy for a production admin panel might read: Include identity-provider group sre OR group platform-oncall; Require login method is the corporate SSO (not one-time PIN) AND device posture shows a managed device; Exclude email departed-contractor@example.com. Read aloud, that is: on-call engineers, signed in through SSO, on a managed laptop, minus one person. The most common mistake is putting a second condition into Include, such as Include group sre plus Include country US. That admits anyone in the US who can authenticate with any configured method, because Include is OR. Constraints belong in Require.
Treat Bypass as dangerous. A Bypass policy on /api/* added to unblock one webhook makes the whole API public. Scope Bypass to the narrowest path, and make sure the origin authenticates those requests itself.
Verifying the token at the origin
Access only protects an application if every request reaches the origin through Cloudflare. If the origin also listens on a public IP, an attacker who finds that address can skip the edge entirely. There are two layers of defence, and you want both. The network layer makes the origin unreachable except through Cloudflare, ideally by having no inbound ports at all and connecting outbound with a tunnel. The application layer makes the origin verify the Cf-Access-Jwt-Assertion header on every request, so that even a request that somehow arrives directly is rejected.
Validation means checking the signature against the team's public keys, published at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs, checking that aud contains the application's Audience (AUD) tag, which you copy from the application's settings, checking that iss is your team domain, and checking expiry. Cloudflare recommends the header over the cookie, because the cookie is only present on browser requests. Here is a minimal Python version with PyJWT:
import jwt # pip install "pyjwt[crypto]"
from jwt import PyJWKClient
from flask import Flask, request, abort, g
TEAM = "https://acme.cloudflareaccess.com"
AUD = "4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2" # from the app's settings
jwks = PyJWKClient(f"{TEAM}/cdn-cgi/access/certs", cache_keys=True)
app = Flask(__name__)
@app.before_request
def require_access():
token = request.headers.get("Cf-Access-Jwt-Assertion")
if not token:
abort(403)
try:
key = jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(token, key, algorithms=["RS256"],
audience=AUD, issuer=TEAM)
except jwt.PyJWTError:
abort(403)
g.user = claims.get("email") or claims.get("sub") # service tokens carry no emailFour details matter. Pin the algorithm list rather than trusting the token's header. Fetch keys by key ID so key rotation works: the certs endpoint returns both the current and the previous key. Use one AUD per application, so a token issued for the wiki cannot be replayed against the admin panel. And never trust an identity header you did not verify, because any header can be forged by a client that reaches the origin directly.
Machines, service tokens and SSH
Scripts, CI jobs and other services cannot complete a browser login. Access handles them with service tokens: a client ID and secret generated in the dashboard, sent as request headers, and matched by a policy whose action is Service Auth and whose Include rule names that token.
curl -sS https://grafana.internal.example.com/api/health \
-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}"Service tokens are long-lived shared secrets, so handle them like any other credential. Store them in a secret manager, give each caller its own token so you can revoke one without breaking the others, set an expiry, and alert before it arrives. The origin still receives a JWT, but it identifies the token, not a person, so your authorization logic must cope with claims that carry no email.
Non-HTTP protocols such as SSH go through cloudflared on the client. Point SSH at it as a proxy command, and the first connection opens a browser for login; after that, the session token is reused until it expires:
# ~/.ssh/config
Host bastion.example.com
ProxyCommand cloudflared access ssh --hostname %hThe server side is a tunnel whose ingress maps that hostname to ssh://localhost:22. Cloudflare also offers a WARP-client-based model for private networks and infrastructure access; its feature set has been evolving quickly, so check the current documentation before you design around it.
Worked example: retiring a VPN
A 60-person company runs Grafana, an internal admin panel and a staging API on a few VMs that today sit behind an OpenVPN server. The goal is to retire the VPN without exposing any port to the internet.
- Install
cloudflaredon each VM and create a tunnel with ingress rules forgrafana.int.example.com,admin.int.example.comandstaging-api.int.example.com. Close all inbound ports in the VM firewall. - Connect the company's identity provider to Access, and create one application per hostname, each with its own AUD.
- Grafana: Allow, Include email domain
example.com, Require SSO login method; session duration 24 hours. - Admin panel: Allow, Include group
ops, Require SSO and a device posture check; session duration 8 hours, so access re-checks within a working day. - Staging API: one Allow policy for the
engineeringgroup, plus a Service Auth policy for the CI system's service token. - Add the JWT check from the previous section to the admin panel and staging API. Configure Grafana's proxy authentication to read the user's email from the verified identity, not from an unverified header.
- Run both paths for two weeks and compare the Access logs with VPN usage, then switch off the VPN.
The result is that each application is reachable only by the group that needs it, rather than by everyone on the VPN. Departures are handled by disabling the identity-provider account, and CI no longer needs a VPN client. The cost is that Cloudflare is now on the critical path for internal access, which is the trade-off to discuss next. Google's Identity-Aware Proxy follows the same pattern if your applications live on Google Cloud and you would rather stay inside one provider.
Failure modes
- Origin bypass. A public origin IP plus no JWT check means Access is decoration. Use a tunnel or lock the firewall to Cloudflare, and verify the JWT.
- Include misuse. Adding constraints as extra Include rules widens access because Include is OR. Review every policy by reading it aloud.
- Over-broad Bypass. A wildcard path Bypass silently publishes an application.
- Identity-provider outage. New logins fail when the IdP is down; existing sessions survive until expiry. Keep a documented break-glass path that is itself protected.
- Long sessions. A 30-day session means a stolen cookie works for 30 days. Use shorter sessions for sensitive applications.
- Leaked service tokens. They work from anywhere unless you also Require an IP range. Rotate them and scope them to one application each.
- Cookie path surprises. Applications that call each other from the browser across hostnames may hit CORS problems, because each application has its own cookie.
Trade-offs
Compared with a VPN, Access gives per-application authorization, identity in every request and no client for web applications. You give up some independence: Cloudflare becomes a dependency for all internal access, and its edge sees the traffic. Compared with building authentication into each application, Access centralises login and policy, but it does not replace authorization inside the application. Access answers "may this person reach the admin panel"; the panel must still decide "may this person delete this customer". Pair it with a web application firewall for public-facing paths, and keep authorization in the application.
Operating Access: logs and policy as code
Access records every login decision, both allowed and denied, with the identity, application, policy and country. Those records answer the questions auditors ask ("who reached the admin panel last quarter?") and the ones on-call engineers ask ("why is Priya being denied?"). The dashboard shows recent events. For retention and correlation with other security data, export them to your log platform or SIEM with Logpush, and keep them as long as your audit policy requires.
Treat policies as code. Manage applications and policies through the Cloudflare API or an infrastructure-as-code tool, review changes in pull requests, and run a small set of automated checks after each change: a request with no credentials must be redirected or denied, a request with a valid service token must succeed, and a request straight to the origin without the JWT header must get a 403. Those three checks catch most regressions, including the over-broad Bypass and the forgotten origin check, before a user or attacker finds them.
What to do next
- Inventory every internal hostname and decide which group should reach each one.
- Protect one low-risk application first, with a tunnel and no inbound ports, and test the login flow end to end.
- Add JWT validation at the origin, pinning the algorithm, AUD and issuer, and prove that a request without the header is rejected.
- Rewrite every policy so that Include names who, Require names the conditions and Exclude names the exceptions; search for and narrow any Bypass policy.
- Issue one service token per automated caller, store each one in a secret manager, and record its expiry.
- Set session durations by sensitivity and document a break-glass procedure for an identity-provider outage.