zudo-cloudflare-wisdom
GitHub repository

Type to search...

to open search from anywhere

Cloudflare Access for Internal Sites

Identity-backed perimeter authorization in front of a Worker -- account-level setup, Worker-scoped applications that cover workers.dev and previews without a zone, the WebSocket limitation, and reading the caller's identity from the Worker

Overview -- Access Is an Allowlist, Not a Signup System

Cloudflare Access puts an identity check in front of a site before any request reaches the origin. A visitor hits the URL, gets redirected to an identity provider, proves who they are, and comes back with a signed session -- all of it enforced at Cloudflare's edge, none of it code you write.

The word that decides where Access fits is allowlist. You enumerate who may enter -- specific email addresses, an email domain, an identity-provider group -- and an administrator edits a policy to admit anyone new. There is no self-service registration, by design.

Access has no signup path, so it cannot be the auth system for a product strangers join

Every consequence follows from the allowlist model. Seats are billed per user, which is fine for a company and nonsensical at consumer scale. Access hands your Worker an identity but no user model -- no profile record you own, no password reset, no email verification, no per-user rows to attach -- so a product built behind it ends up implementing the user system anyway, with Access contributing only the front door. Use Access for internal tools, admin panels, staging deploys, and documentation sites. For anything a stranger signs themselves up for, reach for Auth with Pages Functions or HTTP-only Cookie Sessions.

Three Tools, Three Audiences

Access does not supersede the password gate. They answer different questions about who the viewer is and what it costs to enroll them, and the cheapest correct tool is usually not the strongest one.

Password gateCloudflare AccessApp-level auth
Viewer must be known in advanceNoYes -- allowlisted before first visitNo -- they register
Enrollment cost per viewerZero: send URL + passwordA seat, and an admin editA signup form
Identity in logsNoneEmail, name, groupsWhatever you store
Revoke one personImpossible without rotating for allRemove from the policyDelete their session
Right forHanding a staging link to a client or outside reviewerInternal tools, admin, previews, docsA product with public signup

That first row is the whole distinction. Access needs an identity it can enroll and allowlist before the person shows up. A client reviewing a staging build, a contractor on a two-week engagement, an outside reviewer you cannot add to the corporate directory -- for those, "send the URL and the password" stays the right answer, and a password gate remains the tool. One-time PIN narrows the gap (see below) but does not close it: you still have to know their address ahead of time.

The three compose. A mature setup runs app-level auth for end users, Access over /admin and every preview deployment, and a password gate on the one staging URL that goes out to a client.

Setup Is Account-Level, Not Token-Level

Nothing about an API token decides whether Access is available. Access lives inside Zero Trust, which is turned on per Cloudflare account, and the dashboard is a complete interface to it -- a token is only needed if you intend to manage Access from code.

Three one-time decisions before any policy exists:

  1. A Zero Trust organization. The first visit to the Zero Trust dashboard asks for a team name, which becomes the team domain <team>.cloudflareaccess.com. That hostname is where every login lands and is the iss claim in the resulting JWT. Renaming it later is disruptive -- choose deliberately.

  2. A plan. Check Cloudflare's Zero Trust plan page for the current Free-tier limits. Do not trust a remembered figure -- the widely-repeated "50 users" cap comes from a 2022 document and is not what the current plan page says, and Cloudflare's own account-limits page tiers several other features by plan without stating a user cap at all.

  3. A login method. Either a real identity provider (Google Workspace, Microsoft Entra, Okta) or One-time PIN, which needs no IdP configuration at all: the visitor enters an email address, selects Send login code, and pastes back the PIN that arrives by email. It expires ten minutes after the request.

One-time PIN is no longer enabled by default, and its login page tells a blocked user exactly what it tells everyone else

New Zero Trust organizations default to the Cloudflare identity provider; OTP is no longer added automatically, so it has to be added explicitly under Integrations > Identity providers. And by design the login page always reports A code has been emailed to you, whether or not an email was actually sent -- an address that matches no policy gets the same message and no code. That is deliberate (it refuses to confirm which addresses are allowlisted), but it means "the code never arrived" is the symptom of both a mail problem and a policy miss.

Attaching Access to a Worker Instead of a Hostname

The traditional Access application is bound to a hostname, which historically raised an awkward question for Workers served on *.workers.dev: that is not a zone you own. Access applications can now target the Worker itself, which sidesteps the question entirely.

Coveragedestinations[].typeTakes worker_id
One Worker, production and previewsworkerYes
One Worker, previews onlypreview_workerYes
Every Worker on the account, production and previewsall_workersNo
Every Worker on the account, previews onlyall_preview_workersNo

Cloudflare's Workers documentation states that a Worker-scoped application protects every domain associated with that Worker -- its routes, Custom Domains, workers.dev hostname, and previews. No zone, no custom domain, and no DNS record required.

curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/access/apps" \
  --request POST \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  --json '{
    "type": "self_hosted",
    "name": "Access for my-worker",
    "destinations": [{ "type": "worker", "worker_id": "c81a2d22c29840ed9d61681a3270dbff" }],
    "policies": [{
      "decision": "allow",
      "include": [{ "email_domain": { "domain": "example.com" } }]
    }]
  }'

Two constraints the API schema states and the prose does not: at most one all_workers and one all_preview_workers destination may exist per account, and the destinations list also accepts non-Worker variants (public for a hostname or URI, private for a CIDR/port, via_mcp_server_portal). public is the old hostname-shaped destination -- it is not a Worker-scoped one, and mistaking it for the equivalent of worker is an easy way to build an application that covers nothing you expected.

The docs disagree with each other about hostname-based Access on workers.dev

Cloudflare's Zero Trust page for self-hosted applications still says domains must belong to an active zone in your account, and never mentions workers.dev. The Workers-side page contradicts this for the workers.dev case. No page reconciles the two. Using a worker or preview_worker destination avoids the disagreement completely, because a Worker-scoped application never asks which zone the hostname belongs to -- which is the practical reason to prefer it here, quite apart from the convenience.

Whether version-alias preview URLs are covered is an inference, not a documented promise

The documentation says "previews" without distinguishing the per-version hash URL from a stable --preview-alias URL. The Workers PR Previews flow depends on the alias form specifically. The Preview URLs documentation notes that disabling Preview URLs disables routing to both versioned and aliased preview URLs, which implies a single routing surface and therefore common coverage -- but that is reasoning, not a statement. Confirm with one curl -I against a live alias URL before you rely on it.

WebSocket upgrades fail with 403 behind a Worker-scoped policy

Worker-level Access policies do not currently support WebSocket connections -- an upgrade request to a Worker protected by one fails with 403. Cloudflare's own guidance is to protect anything using WebSockets with a hostname-based application instead, explicitly naming Durable Objects, real-time applications, and RDP-over-WebSocket. This is the single hard constraint that decides between the two mechanisms, so check it before designing around Worker destinations.

Precedence, and Why Deleting a Policy May Change Nothing

The most specific rule wins: hostname- or path-based Access first, then Worker-level, then account-level all_workers as the fallback. The consequence worth internalizing -- and which Cloudflare states directly, that removing Worker-level Access can reveal account-level Access underneath -- is that on an account running all_workers, deleting a Worker's own policy does not make that Worker public. It falls through to the account-wide rule.

Exempting one Worker takes an explicit bypass, not a deletion:

{
  "decision": "bypass",
  "include": [{ "everyone": {} }]
}

applied to a worker destination for the Worker in question.

What the Visitor Actually Sees

With an identity provider already configured -- the normal case in a company that uses Zero Trust for anything else -- most people type nothing at all:

graph TB Visit["Visitor opens /items/123"] --> Check{"Valid Access session cookie?"} Check -->|Yes| Serve["Worker runs, page loads"] Check -->|No| Redirect["302 to team.cloudflareaccess.com"] Redirect --> Picker["Cloudflare login page: app name, logo, one button per method"] Picker --> IdP["IdP round-trip (usually already signed in)"] IdP --> Policy{"Matches an allow policy?"} Policy -->|Yes| Back["Back to /items/123, session cookie set"] Policy -->|No| Blocked["Cloudflare block page"] Back --> Serve

Two details that matter in practice:

  • The original deep link survives. A visitor who clicks a link to /items/123 lands back on /items/123, not on the site root. Shared links keep working.

  • Denied users get Cloudflare's block page, not your 404. A colleague who is not on the policy sees a generic refusal with no idea who to ask. The block page is customizable -- put a name or a channel on it, or the first support request will be someone asking why the link is broken.

With One-time PIN in place of an IdP, the visitor types twice: their email address, then the six-digit code that arrives by email. Only addresses matching a policy get a code that works.

How often anyone re-authenticates is configurable, at three levels that are easy to conflate. Application session duration (Applications > Configure > Overview) ranges from immediate timeout to one month and defaults to 24 hours. A policy may override it for the people that policy matches, over the same range. Global session duration is separate again -- it governs IdP re-login account-wide, runs from 15 minutes to one month, and also defaults to 24 hours. A Cloudflare One Client session, where one exists, overrides all of them.

Reading the Identity in the Worker

Once a request is through, the Worker can read who sent it -- no JWT parsing, no library:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // ctx.access is undefined when Access did not authenticate the request
    // -- e.g. the Worker is reachable on a route the application does not
    // cover. Treat that as a failure, not as an anonymous visitor.
    if (!ctx.access) {
      return new Response("Access required", { status: 403 });
    }

    const identity = await ctx.access.getIdentity();
    return new Response(`Signed in as ${identity?.email ?? "unknown"}`);
  },
};

The application's audience tag is on the same object, as ctx.access.aud.

This is the capability a shared password fundamentally cannot offer: per-visitor attribution. Recording who triaged an item, or showing the signed-in user, becomes possible without writing an auth system.

Only email is safe to rely on by name -- getIdentity() has no published return type

The Workers page describes the result as "email, groups, device posture" and the changelog says "email, name, and groups", but neither documents a signature, and the identity table they link to lists email, idp, geo, user_uuid, devicePosture, and others with no top-level name or groups field. Read identity?.email and treat anything else as unverified until you have logged an actual response from your own deployment. (The changelog also links getIdentity() to a #access anchor on the Context runtime-API page that does not exist -- there is no reference page behind this API yet.)

For hostname-based applications, or anywhere explicit verification is wanted, validate the Cf-Access-Jwt-Assertion header against the team's JWKS endpoint instead. Fetch the keys; never hardcode one:

import { createRemoteJWKSet, jwtVerify } from "jose";

// Module-level cache so one isolate reuses a single JWKS fetcher across
// requests. It cannot be built at module scope directly: `env` only exists
// inside the handler, so the URL is not known until the first request.
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();

function getJwks(teamDomain: string) {
  let jwks = jwksCache.get(teamDomain);
  if (!jwks) {
    jwks = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`));
    jwksCache.set(teamDomain, jwks);
  }
  return jwks;
}

export async function verifyAccessJwt(request: Request, env: Env) {
  const token = request.headers.get("Cf-Access-Jwt-Assertion");
  if (!token) throw new Error("Missing Cf-Access-Jwt-Assertion header");

  return await jwtVerify(token, getJwks(env.TEAM_DOMAIN), {
    issuer: env.TEAM_DOMAIN,
    audience: env.POLICY_AUD, // the application's AUD tag
  });
}

TEAM_DOMAIN here is the full origin -- https://my-team.cloudflareaccess.com, scheme included and no trailing slash. It is used twice for two different reasons: new URL() rejects a bare hostname outright, and the iss claim Access signs is the origin form, so a value that omits the scheme fails the issuer check even where the URL happens to build.

The same RS256-plus-JWKS shape appears in Auth with Pages Functions -- Access is one more third-party issuer, verified the same way.

Simulating an Identity in Local Dev

wrangler dev has no Access in front of it, so ctx.access would be undefined for every local request and the guard above would reject everything. An access.dev block supplies a fake identity instead:

{
  "access": {
    "dev": {
      "aud": "my-app", // required -- Wrangler will not start without it
      "identity": { "email": "admin@example.com" } // optional
    }
  }
}

Remove the block and ctx.access goes back to undefined, which is the way to test the unauthenticated branch.

This key is undocumented in the Wrangler configuration reference

access.dev appears only on the Workers Access page and the announcement changelog -- the Wrangler configuration reference does not list an access key at all. Cloudflare describes its purpose as simulating identities "without deploying", but no page states that the block is stripped on upload. Do not assume it is inert in production on the strength of its name; if that matters to you, keep it out of the deployed configuration rather than trusting it to be ignored.

API Token Permissions Are Separate from the Deploy Token

Deploying a Worker that sits behind Access needs no Access permission on the deploy token. Access applications are created through /accounts/{account_id}/access/apps, an API surface the Worker upload never touches, and the deploy token Cloudflare's own dashboard generates contains no Access: permission at all. Enabling Access does not break an existing deploy pipeline, and the deploy token should stay as narrow as it already is (see Cloudflare Account Setup).

Managing Access from code -- Terraform, or direct API calls -- is what needs permissions. Create a separate token for it. All of them are Account-scoped:

PurposeDashboard labelAPI / Terraform name
Applications and policiesAccess: Apps and Policies : EditAccess: Apps and Policies Write
Identity providers and groupsAccess: Organizations, Identity Providers, and Groups : Edit...Write
Service tokensAccess: Service Tokens : Edit...Write

The dashboard says "Edit" where the API says "Write"

Cloudflare's permission reference renders the same table twice, once with dashboard labels and once with API permission-group names. They are the same permission under two strings, and searching Terraform documentation for "Edit" -- or the dashboard for "Write" -- finds nothing. A zone-scoped Access: Apps and Policies also exists, but it does not cover Worker-scoped applications, which are account-level objects.

Revision History

CreatedUpdated