ABS guide draft · setup · fix-pass applied after NEEDS_FIXES review

Cloudflare Access for Non-Public Tunnels — Email-OTP, SSO, and Service Auth

Gate a Cloudflare Tunnel behind Cloudflare Access so it is no longer public: pick the right auth pattern, add the policy, and let an agent do the API work.

Review copy · Updated July 22, 2026

Cloudflare Access for Non-Public Tunnels — Email-OTP, SSO, and Service Auth

Your Cloudflare Tunnel already serves a hostname. The byte path works — but anyone on the public internet can reach it, because a tunnel is authenticated outbound transport, not an auth layer at the edge. For an admin panel, a staging site, an internal dashboard, or any tool only your team should see, that is the gap.

Cloudflare Access sits at Cloudflare’s edge, in front of the tunnel. Visitors get an identity challenge before their request ever crosses your outbound connector. By the end of this guide you will have added a real Access policy that gates a tunnel-routed hostname — humans (OTP or SSO) and machines (Service Auth) — and verified the gate with two curl probes. Assumes you already have a working tunnel; if not, the Tunnel Setup guide is the place to start.

What Cloudflare Access actually does — and what it does not

Cloudflare Access is an identity layer at Cloudflare’s edge, in front of the tunnel. Cloudflare’s edge checks the Access policy before any byte is proxied. If the visitor matches an Allow rule, the request is forwarded over the tunnel to your origin; if not, the edge returns a 302 to the Access login page and never touches the tunnel.

Consequences:

  • Access is deny-by-default. A new application with no policy denies everyone (policy docs).
  • Access requires a Cloudflare-managed hostname. The decision happens at the edge, so the protected hostname must be served by Cloudflare DNS proxied through the tunnel.
  • Access does not see your local auth. If the origin already has Basic Auth or its own form, Access and the origin auth compete for the redirect chain — see Common failures.

The three auth patterns — when to use each

Cloudflare Access has four policy actions — Allow, Block, Bypass, Service Auth — but the selector you combine with Allow (or with Service Auth for machine callers) is the choice that decides who gets through.

PatternUse this whenVisitor experienceCost
Email-OTP (One-time PIN)Personal tools, single-team internal apps, low-traffic admin panels where every visitor has a real inbox.Visitor enters email, receives a 6-digit code, pastes it. Subsequent visits are cookie-authenticated for the session duration (24h default).Free, ships with every Zero Trust account.
SSO (Google / GitHub / Okta / Entra ID / etc.)Team tools where every member has an account at an identity provider you can federate with. Pick this to delegate password resets and MFA to the IdP.Visitor clicks “Sign in with Google” (or whichever provider), completes the IdP flow, lands on the app.Free up to the Zero Trust 50-seat limit.
Service Auth (tokens or mTLS)Cron jobs, CI runners, agent workers — any non-human caller needing programmatic access.The caller sends two HTTP headers (Cf-Access-Client-Id, Cf-Access-Client-Secret) on every request, or presents a client certificate at the TLS handshake.Free, within the 50 service tokens per account limit.

Pick the pattern by who is knocking: tokens are for machines, humans use SSO or OTP, and many real deployments run both on one application. Cloudflare’s policy order of execution evaluates Service Auth and Bypass first (top to bottom), then Allow/Block — so a valid service-credential request short-circuits the human login.

Before you start

You need four things:

  • A working Cloudflare Tunnel routing a hostname you want to protect. If not, finish the Tunnel Setup guide first. Today curl -sS -I https://<hostname>/ should return HTTP/2 200; after Access is in front, it will return HTTP/2 302.
  • A Cloudflare Zero Trust account (free tier covers this guide; sign up). The same Cloudflare account that owns the DNS zone must own the Zero Trust org.
  • A decision on which auth pattern. If unsure, default to OTP for humans plus a Service Auth token for machines — they coexist cleanly.
  • A secret store for service-token secrets. Service tokens are two long random strings; the secret is shown once at creation. A leaked service token is functionally a password to your hostname.

Do not install anything new on the host. The tunnel stays as-is; Access is configured in the Zero Trust dashboard (and optionally the Cloudflare API).

Step 1 — Add the Access application for the protected hostname

🤖 Let your agent do it: say — Create a Cloudflare Access self-hosted application for hostname <hostname> in account <CF_ACCOUNT_ID> via POST https://api.cloudflare.com/client/v4/accounts/<CF_ACCOUNT_ID>/access/applications with API token <CF_API_TOKEN>. Do not attach any policy yet. Return the application ID.

The agent will POST the shape and return the application ID. Keep that ID — you attach the policy to it next.

Dashboard: Zero Trust → Access controls → Applications → Create new application → Self-hosted and private → Add public hostname (Self-hosted is the right pick for a tunnel-fronted hostname; SaaS protects someone else’s app). Pick the Domain from the dropdown — wildcards (*.yourteam.com) cover multiple paths under one root. Set a memorable Name, the Session duration (24h default), and the Identity providers to enable. IdPs are configured separately under Integrations → Identity providers (Step 2a for OTP, 2b for SSO); service tokens get created in Step 3. Save — the dashboard forwards you to Policies.

Step 2 — Add an Allow policy for humans

The Allow policy matches real visitors. Its building blocks are actions (Allow / Block / Bypass / Service Auth), rule types (Include / Require / Exclude), selectors (the attribute), and values (the match data). For a single-team tool where every member has an email at one domain:

ActionRule typeSelectorValue
AllowIncludeEmails ending in@yourteam.com

Anyone whose email domain matches yourteam.com — validated against the IdP from Step 2a or 2b — passes; everyone else is denied.

To require a specific login method (e.g. only allow users who signed in via Google), add a Require rule:

ActionRule typeSelectorValue
AllowIncludeEmails ending in@yourteam.com
RequireLogin Methodgoogle

A visitor pasting a personal Gmail address fails the Require rule.

For automation, the JSON shape (via POST /accounts/{id}/access/apps/{app-id}/policies):

{
  "name": "team-allow",
  "decision": "allow",
  "include": [{ "email_domain": "yourteam.com" }],
  "require": [{ "login_method": { "method": "google" } }]
}

The four keys (decision, include, require, exclude) and the action vocabulary (allow / block / bypass / service_auth) are in the Access policy docs.

Step 2a — Set up One-time PIN (email-OTP)

New Zero Trust organizations default to the Cloudflare identity provider; OTP is no longer added automatically and must be configured before visitors can use it.

Dashboard: Zero Trust → Integrations → Identity providers → Add new identity provider → One-time PIN. Save — no other fields are required.

API (requires Access: Organizations, Identity Providers, and Groups Write on the API token):

# Read the API token from your local environment. Never paste a real
# token into a shell history, a chat message, or a published guide.
CF_TOKEN_VAR="<your-CF-API-token>"     # [REDACTED] — load from env or a secrets manager, not literal
ACCOUNT_ID="<your Cloudflare account ID>"

curl "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/access/identity_providers" \
  --request POST \
  --header "Authorization: Bearer ${CF_TOKEN_VAR}" \
  --json '{ "name": "One-time PIN login", "type": "onetimepin", "config": {} }'

The visitor enters their email on the Access login page, selects Send login code, and pastes the 6-digit PIN from [email protected]. Each PIN is single-use and expires after 10 minutes; requesting a new code invalidates the previous one.

🤖 Let your agent do it: say — POST a One-time PIN identity provider to https://api.cloudflare.com/client/v4/accounts/<CF_ACCOUNT_ID>/access/identity_providers with body {"name":"One-time PIN login","type":"onetimepin","config":{}}. Confirm the response status is 200.

Step 2b — Set up SSO (Google example)

The steps below use Google (no Workspace required); Microsoft Entra ID, Okta, GitHub, and generic OIDC/SAML providers follow the same shape — see the IdP index.

In the Google Cloud Platform console (one-time): create a project, then APIs & Services → Credentials → Configure Consent Screen (External audience). Create OAuth client → Web application. Under Authorized JavaScript origins, add https://<your-team-name>.cloudflareaccess.com (your team name is at Zero Trust → Settings → Team name and domain). Under Authorized redirect URIs, add https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callback. Copy the OAuth Client ID and Client secret.

In Cloudflare: Zero Trust → Integrations → Identity providers → Add new identity provider → Google, paste the Client ID and Client secret, save. Click Test next to Google — a window should open with Your connection works! (re-check the origin and redirect URIs character-for-character if it errors). Open the application from Step 1 and select Google alongside OTP. To send visitors straight to Google without the Access login chooser, turn on Apply instant authentication.

The visitor then clicks Sign in with Google, completes the consent flow, and lands on the protected hostname.

🤖 Let your agent do it: say — POST a Google identity provider to https://api.cloudflare.com/client/v4/accounts/<CF_ACCOUNT_ID>/access/identity_providers with body {"name":"Google","type":"google","config":{"client_id":"<GOOGLE_CLIENT_ID>","client_secret":"<GOOGLE_CLIENT_SECRET>"}}. Confirm the response status is 200.

Step 3 — Add a Service Auth policy for machine callers

A service token is a static (Client ID, Client Secret) pair sent as headers on every request. The dashboard names the action Service Auth; the caller experience is “send two HTTP headers.”

🤖 Let your agent do it:

Create a Cloudflare Access service token named "<token-name>" in account <CF_ACCOUNT_ID>,
then attach it as a Service Auth policy to the Access application <APP_ID>.
POST to https://api.cloudflare.com/client/v4/accounts/<CF_ACCOUNT_ID>/access/service-tokens
then POST to /access/apps/<APP_ID>/policies with
decision=service_auth, include=[{"service_token": ["<token-id>"]}].
Return the Client ID and Client Secret — I need to store the secret.

The Cloudflare API returns the secret only at creation time. Save it before you close the chat session.

Manual path: Zero Trust → Access controls → Service credentials → Service Tokens → Create Service Token, name it, copy the Client ID and Client Secret (the secret will not be shown again). Then on the application from Step 1: Policies → Add a policy, Action Service Auth, Rule type Include, Selector Service Token, pick the token.

Service Auth evaluates before Allow (Cloudflare’s two-stage order: Service Auth and Bypass first, then Allow/Block — see the policy docs). Valid service-credential requests bypass the human login; everything else falls through to Allow. Both coexist on one application.

The caller sends:

Cf-Access-Client-Id: <token-id>
Cf-Access-Client-Secret: <token-secret>

For mTLS instead of tokens, the Service Auth action with selector Valid certificate accepts a client certificate at the TLS handshake — the right choice when the caller is already inside a Zero Trust posture-check framework.

Step 4 — Verify the gate from the terminal

# 1. Unauthenticated — default expected: 302 redirect to Access login
curl -sS -I --max-redirs 0 https://mockups.yourteam.com/ | head -1

# 2. Service-token — default expected: 200 from your origin
curl -sS -I \
  -H "Cf-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
  -H "Cf-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
  https://mockups.yourteam.com/ | head -1
ProbeDefaultWhat you might see
No authHTTP/2 302 to *.cloudflareaccess.com200: Access not attached. 403: policy denies everyone. 502/503: tunnel down.
Both Cf-Access-* headersHTTP/2 200 from origin302: origin-level redirect. With 401 Response for Service Auth policies on, expect 401. Token rejection is 403, not 302.

Open the protected hostname in a private browser window to confirm a visitor matching the Allow policy lands through.

Step 5 — Subscribe to the Access audit log

Access authentication events live at Zero Trust → Insights → Logs → Access authentication logs. The dashboard view shows identity-based events (login attempts via an IdP, including OTP and SSO). Non-identity events (IP, posture, country, valid certificate, service token) are not in the dashboard — pull those via the GraphQL analytics API. Subscribe to two patterns from day one: a service token used from a new IP (rotate immediately); and a burst of denied attempts from one IP (decide whether to add a WAF rule). The identity-event endpoint is GET /accounts/{id}/access/logs/access_requests.

Common failures

Redirect loops (ERR_TOO_MANY_REDIRECTS)

Access returns a 302 to the login page, the page sets a cookie and redirects back, and the origin’s Basic Auth (or its own form) returns a 302 back to the login — chain loops. Two common root causes: competing auth (the origin still enforces its own auth after Access is attached) and cookie-domain mismatch (the Access cookie is on a different subdomain than the protected hostname). Two fixes: (1) drop the origin’s auth_basic directive; (2) move Access to a hostname the origin does not know about.

Policy denies everyone after first deploy

curl -sS -I --max-redirs 0 https://<hostname>/ returns HTTP/2 403 instead of 302. Likely causes: Email selector with a single address where the IdP returns a different case or +tag (switch to Emails ending in); Country selector with a value the dashboard does not recognise (the dashboard shows display names like United States — match against the dropdown, not ISO codes); or only a Service Auth policy exists where the visitor is a human. Access is deny-by-default; the Test your policy preview runs a dry-run — use it before saving.

Service-token “auth failed” on a request that worked yesterday

Three causes in order of likelihood: rotated (dashboard one-click rotation invalidates the old secret immediately); deleted (service tokens do not auto-recover); or the Cf-Access-Client-Secret value does not match exactly what the dashboard returned. Headers are matched byte-for-byte — a stray space, tab, newline, or quote fails validation silently. Read the secret with secret=$(cat path/to/secret) and reference the variable in curl.

Wrong team URL / Access on a public page

Visitors land on a generic Cloudflare login page that does not match your team name — fix under Settings → Custom Pages → Team URL. Separate failure mode: Access enabled on a public-facing page. Access is for non-public surfaces; for the public, use rate limiting or a WAF.

Updates, breaking changes, and where to get unstuck

Re-run the Step 4 verification probes after any Access policy edit, after any tunnel credential rotation, and after any Cloudflare account-level change. A policy that worked last week can be silently denied by an unrelated IdP change. For unblocking: the Access policies docs are the canonical source; the API and Terraform reference beats any third-party blog for CLI-driven automation; and for the underlying tunnel, the Tunnel Troubleshooting guide on ABS.

Sources

Last verified: 2026-07-22 (post-review refresh). Drafted by MiniMax M3 subagent dispatch; the three review fixes (current OTP and SSO working examples; corrected account-limits and logs visibility; tightened troubleshooting) were applied in this pass per the parent-dispatched independent subagent review.