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 gate | Cloudflare Access | App-level auth | |
|---|---|---|---|
| Viewer must be known in advance | No | Yes -- allowlisted before first visit | No -- they register |
| Enrollment cost per viewer | Zero: send URL + password | A seat, and an admin edit | A signup form |
| Identity in logs | None | Email, name, groups | Whatever you store |
| Revoke one person | Impossible without rotating for all | Remove from the policy | Delete their session |
| Right for | Handing a staging link to a client or outside reviewer | Internal tools, admin, previews, docs | A 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 / 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:
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 theissclaim in the resulting JWT. Renaming it later is disruptive -- choose deliberately.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.
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.
| Coverage | destinations[].type | Takes worker_id |
|---|---|---|
| One Worker, production and previews | worker | Yes |
| One Worker, previews only | preview_worker | Yes |
| Every Worker on the account, production and previews | all_workers | No |
| Every Worker on the account, previews only | all_preview_workers | No |
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:
Two details that matter in practice:
The original deep link survives. A visitor who clicks a link to
/lands back onitems/ 123 /, not on the site root. Shared links keep working.items/ 123 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:, 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 /, 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:
| Purpose | Dashboard label | API / Terraform name |
|---|---|---|
| Applications and policies | Access: Apps and Policies : Edit | Access: Apps and Policies Write |
| Identity providers and groups | Access: Organizations, Identity Providers, and Groups : Edit | ...Write |
| Service tokens | Access: 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.
Related
Password Gate for Preview and Staging Sites -- the shared-secret alternative, and still the right tool for outside viewers who cannot be allowlisted in advance.
Access Service Tokens for CI -- how Playwright smoke tests and health checks get through an Access-protected URL without a login redirect.
Auth with Pages Functions -- verifying a third-party IdP token, and the path to take when the app has public signup.
HTTP-only Cookie Sessions -- the per-user session layer Access deliberately does not provide.
Workers PR Previews -- the preview-alias URLs a
workerdestination is meant to cover.Cloudflare Account Setup -- the deploy token, which needs none of the permissions on this page.