publishing-behind-cloudflare-access
Skill4 starsChanged 11 days agoMITView on GitHub
Use when a page, artifact, report, or static site must go online but stay restricted to named people - someone asks to "password protect" a page, share a document with specific emails only, gate a preview or staging site, put authentication in front of something that has no backend, or keep a confidential deliverable off the public internet. Also use when a published Claude artifact needs an audit trail of who opened it, and whenever a private page is about to be deployed before its access controls exist.
In this repository10 other files it gives its agents.
- accessibility-auditSkill
- asking-for-decisionsSkill
- cloudflare-migration-sweepSkill
- dependency-auditSkill
- getting-command-outputSkill
- passing-json-between-agentsSkill
- refactoringSkill
- sandboxed-executionSkill
- session-handoffSkill
- tracker-consent-auditSkill
The file
We split this skill into the rules it gives an agent
11 rules. About 0 are common, 7 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 8
Put a login in front of a static page using Cloudflare Access, without buying a domain, moving DNS, or writing auth code.
- Distinctive: line 19
Every other instruction here is detail.
- Distinctive: line 76
A static-only Worker trusts Access completely: If the Access app is deleted while the public URL is on, the page is public at once, and a …
- Very specific: line 100
A recreated Access app has a new AUD tag: Update ACCESS_AUD, or everyone gets 403.
- Very specific: line 101
Test the closed side locally: no token, junk, a forged signature with an unknown key, an unknown key ID and alg: none must all return 403.
- Distinctive: line 107
Fix a policy by editing it, never by deleting the app: If the app must be recreated, turn the public URL off first.
- Very specific: line 112
The Access bootstrap is dashboard-only: Before it is done, every Access API call returns 9999: access.api.error.not_enabled, including …
- Very specific: line 133
If the probe fails, deployment has to run through npx wrangler under the user's own login, and npx wrangler login is an interactive browser …
- Distinctive: line 160
Do all three. The first two are the ones people skip.
- Distinctive: line 166
Only then share the link.
- Distinctive: line 168
Check 1 can be automated and should be, because it also proves nothing leaks before the redirect:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Publishing behind Cloudflare Access
Put a login in front of a static page using Cloudflare Access, without buying a domain, moving DNS, or writing auth code.
The core hazard
Deploying the page and adding the login are two steps, and doing them in that order publishes the document. A confidential page on a live URL is exposed for however long the second step takes, and that window is indexable and unrecoverable.
Deploy with the public URL switched off, attach Access, then switch it on. Every other instruction here is detail; this is the one that matters.
Answer the question they actually asked
People ask for a password. Access does not do shared passwords, and that is a better outcome. Say so plainly and move on:
| They want | Give them | Why |
| A password on the page | Email allowlist | A password gets forwarded, can't be revoked per person, and leaves no trace of who used it |
| Only certain people | Access policy listing addresses | Exactly the feature |
| To know who read it | Zero Trust > Logs > Access authentication | Records every sign-in |
| To cut someone off | Remove from policy and revoke tokens | Removal alone leaves live sessions running |
Free for up to 50 users. Two recipients costs nothing.
Sequence
- Enable Access once, in the dashboard. Zero Trust > Enable Access. Pick a
team domain (
yourteam.cloudflareaccess.com). Not automatable, see below. - Deploy with no public route.
"workers_dev": falseinwrangler.jsonc. The Worker exists and is unreachable. Pick the Worker name here, and pick a meaningless one: it becomes the public hostname. - Attach Access to the Worker. Workers & Pages > your Worker > Access > Protect this Worker behind Access > All traffic. Policy: sign-in by email address, listing each person.
- Turn the URL on. Set
"workers_dev": true, redeploy. - Verify before sending the link. See below.
Attaching Access to the Worker rather than to a hostname covers its
workers.dev URL, every route, custom domains, and previews at once, and keeps
covering them when routes change. It also means no domain is required, which
removes the whole DNS question.
Previews matter more than they look, because they switch themselves on. With
workers_dev true and no preview_urls setting, wrangler enables Preview URLs
by default and says so in a warning that is easy to scroll past:
▲ [WARNING] Because your 'workers.dev' route is enabled and your 'preview_urls'
setting is not in your Wrangler file, Preview URLs will be enabled by default.That is a second public hostname, one per deployed version, that nobody chose. Worker-scoped Access does cover it - measured, both the current and a previous version redirect to the login with no content served:
curl -s -o /dev/null -w "%{http_code}\n" https://<version>-<worker>.<subdomain>.workers.dev
# 302, and the body carries none of the documentHad Access been attached to the hostname instead, every preview URL would have been an open door. Check one after deploying rather than assuming.
Fail closed
A static-only Worker trusts Access completely. If the Access app is deleted while the public URL is on, the page is public at once, and a Worker with no code keeps no request log to say who loaded it. Measured on a real report: deleting the app in the dashboard while fixing a policy left it public for about 50 seconds, with no way to know whether anyone else saw it.
Make the Worker check the Access token itself. Route every request through it and
return 403 unless Cf-Access-Jwt-Assertion verifies:
// wrangler.jsonc
"main": "src/index.ts",
"assets": { "directory": "./public", "binding": "ASSETS", "run_worker_first": true },
"vars": {
"ACCESS_AUD": "<the Access application's AUD tag>",
"ACCESS_TEAM": "https://<team>.cloudflareaccess.com"
}references/fail-closed-worker.ts is the Worker to use as src/index.ts. It
verifies the RS256 signature against <team>/cdn-cgi/access/certs, the AUD tag,
the issuer and expiry, refetches the keys once for an unknown key ID, and serves
ASSETS only when all pass.
- A recreated Access app has a new AUD tag. Update
ACCESS_AUD, or everyone gets 403. - Test the closed side locally: no token, junk, a forged signature with an unknown key,
an unknown key ID and
alg: nonemust all return 403. Those cases stop at the key lookup. OnceACCESS_TEAMandACCESS_AUDare filled, also send a forged signature with a real key ID (the firstkidfrom<team>/cdn-cgi/access/certs) and the real aud and iss. Only that case reaches the signature check, and it must return 403 too. The open side needs a real sign-in in a browser. - Fix a policy by editing it, never by deleting the app. If the app must be recreated, turn the public URL off first.
What you cannot automate, and how to find out fast
The Access bootstrap is dashboard-only. Before it is done, every Access API
call returns 9999: access.api.error.not_enabled, including reads. Creating the
organization over the API does not substitute for the button.
Cloudflare MCP tokens are commonly read-only. Reads succeed and writes fail
with 10000: Authentication error, which reads like a broken credential rather
than a scope limit. Probe before planning around it:
POST /accounts/{account_id}/access/groups # then DELETE it
{ "name": "zz-perm-probe-delete-me",
"include": [{ "email": { "email": "probe@example.invalid" } }] }Probe with something that cannot fail validation. An Access group gates
nothing until a policy references it, and its body is too simple to be rejected,
so the only thing it can test is permission. Probing with an application
instead returns 1010 when the destination list is empty - a validation error,
not a permission one - and reading that as "no write access" is wrong in a way
that changes the whole plan. Only 10000: Authentication error means scope.
If the probe fails, deployment has to run through npx wrangler under the user's
own login, and npx wrangler login is an interactive browser flow you cannot
complete for them. Establish this before writing any deploy code.
Worth knowing the two tokens fail differently. A wrangler OAuth login typically
carries workers (write) and no Access scope at all, so it deploys happily and
cannot create the application. Check with npx wrangler whoami, which prints the
scope list.
Gotchas that cost real time
| Gotcha | What to do |
| The hostname is public even though the page is not. Access gates the content, not the URL | Name the Worker so it reveals nothing. acme-2027-pricing.workers.dev announces the subject in every link, proxy log, mail scanner and browser history. Use something opaque. |
| "Protect all Workers" locks every Worker in the account, including unrelated ones | Protect the single Worker. Check what else is in the account first. |
| Moving a business domain to Cloudflare to host one page | Nameserver change moves MX too, risking email. Use *.workers.dev instead. |
| One-time PIN is no longer the default login method | Since June 2026 new orgs get the Cloudflare IdP. Add OTP explicitly if the recipient has no Cloudflare account. |
allowed_idps: [] read back from the API looks like "no logins allowed" | It means the opposite: unrestricted, so every configured provider is offered. The per-Worker dashboard flow leaves it empty, and an app created another way may have OTP pinned - so two apps on one account can differ here and both be right. Compare the rendered login pages before changing anything; with OTP configured they came back identical. Do not "fix" the empty one. |
| Reading a config back is not the same as testing it | Policies are the gate, not the app record. An application can exist, be bound to the right Worker, and admit nobody, because the policies live on a separate endpoint (/access/apps/{id}/policies). Fetch those too. |
| Blocked users still see "a code has been emailed to you" | By design. Judge by whether the code works, never by the message. |
| An account member testing the page gets no PIN, and reads that as a failure | If the tester's address is a Cloudflare account member and a policy admits account members, Access signs them in through the Cloudflare IdP with no email at all. Measured: connection: "cloudflare", allowed: true, no PIN sent. It is correct behaviour that looks exactly like being blocked, because you sit waiting for a mail that was never coming. It also tests the wrong path - recipients are not account members, so they all arrive by one-time PIN. Test with an address that matches a policy but is not an account member. |
| Removing someone from the policy leaves their session alive | Sessions last session_duration (24h default). Use Revoke tokens for immediate cutoff. |
Artifact HTML has no <!doctype>, <html>, <head>, or <body> | The artifact publisher injects them. Self-hosting does not. Wrap it, and add <html lang="en"> plus <meta name="robots" content="noindex, nofollow, noarchive">. |
| Email scanners consume the PIN before the user does | Symptom is "This One-Time PIN has already been used". Allowlist noreply@notify.cloudflare.com. |
Verify before sending the link
Do all three. The first two are the ones people skip.
- Private window on the URL: the login page appears, not the document.
- An address not on the allowlist: refused.
- Your own address: code arrives, page loads.
Only then share the link.
Check 1 can be automated and should be, because it also proves nothing leaks before the redirect:
curl -s -o /tmp/b -D /tmp/h -w "%{http_code}\n" "$URL" # expect 302
grep -i '^location:' /tmp/h # expect cloudflareaccess.com
grep -qiE "<a distinctive phrase from the document>" /tmp/b && echo EXPOSED || echo cleanChecks 2 and 3 cannot be automated and must not be skipped, because they are the only ones that test the policy rather than the plumbing. Check 1 passes identically whether the policy admits the right five people or the wrong five hundred. If you are an agent, hand these two back explicitly rather than reporting the page verified.
Read the result in the log, not in the inbox. "No code arrived" has three
causes that are indistinguishable from a browser and obvious from
Zero Trust > Logs > Access authentication, or
GET /accounts/{account_id}/access/logs/access_requests, which is a read and so
works on a read-only token:
connection | Means |
cloudflare | Signed in via the Cloudflare IdP. No PIN was ever sent. |
onetimepin | The PIN path worked, which is what recipients will use. |
| no entry at all | No successful login. This is what a correctly refused address looks like - note it is not an explicit deny, because a refused address never gets far enough to produce one. |
When not to use this
For a single recipient and a one-off document, a share menu on whatever already hosts the page is proportionate and takes no setup. Access earns its setup when you want the audit trail, per-person revocation, or expect to publish this way again.
Reference
references/api-recipes.md carries exact request shapes for the Access application,
Worker destination types, one-time PIN provider, and the wrangler config, plus
the rollback commands. references/fail-closed-worker.ts is the token-checking
Worker from the Fail closed section.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

