Lock the CMS behind Cloudflare Access
Issue: #6. Question: what is the reference pattern for keeping the CMS behind Cloudflare Access while a public Worker serves the site?
Result
The pattern has four layers, and the first three are now code in this repo:
| Layer | What it does | Where |
|---|---|---|
| Access application | Puts the CMS hostname (or only /admin and /api for Payload) behind an Access login, with a non_identity policy for the Worker’s service token |
packages/access → provisionLockdown, scripts/provision-access.ts |
| Worker → CMS | The public Worker calls the CMS with its service token, and for previews forwards the editor’s Access JWT with a gateway proof | packages/access → accessFetch |
| CMS verifies | WordPress: an origin guard that rejects requests without a valid Cf-Access-Jwt-Assertion, and REST/WPGraphQL authentication from a forwarded JWT. Payload: a custom auth strategy |
wordpress-shared/patronage-access, packages/access/payload |
| Editor SSO | Editors reach wp-admin or the Payload admin through Access | Payload: the strategy above. WordPress: not ported yet (see follow-ups) |
Everything is unit-tested (38 TypeScript tests, two PHP suites, one shared known-answer vector for the gateway proof). The live run passed on 2026-10-30, with the WordPress CMS, its front-end previews and the Payload admin and API each behind their own Access application (see Live run below).
How a request flows
visitor ──► web Worker (public) ──service token──► Access app ──► CMS origin
│ │
sets Cf-Access-Jwt-Assertion origin guard verifies it
(service identity) (rejects anything that skipped Access)
editor ──► Access login ──► web Worker /preview ──service token + X-Access-User-Jwt + proof──► CMS
the CMS maps the forwarded JWT's email to a user
- Public pages. The Worker sends
CF-Access-Client-IdandCF-Access-Client-Secret. Access admits it through thenon_identitypolicy and hands the origin a JWT whose identity is the token’scommon_name, with no email. The CMS treats that as “came through Access, anonymous”: published content only. - Previews and drafts. These need a real user. Put the Worker’s preview route behind its own Access application so the Worker receives the editor’s JWT, then forward it as
X-Access-User-JwtwithX-Access-Gateway-Proof. WordPress authenticates REST and WPGraphQL as the user with that email, so drafts follow WordPress’s own permissions. SetPATRONAGE_ACCESS_AUDto the preview application’s AUD andPATRONAGE_ACCESS_ORIGIN_AUDto the CMS application’s. - Editors go straight to the CMS hostname, log in through Access, and the CMS sees their JWT on every request.
Findings
Access on the hostname is not enough; the origin has to check too. Access only filters traffic that comes through Cloudflare. A WordPress host still answers on its own platform URL or IP. Two ways to close that: a Cloudflare Tunnel (the origin has no public address), or verifying Cf-Access-Jwt-Assertion at the origin. Managed WordPress hosts generally cannot run cloudflared, so the JWT check is the portable default; the plugin’s origin guard does it with PATRONAGE_ACCESS_REQUIRE_ORIGIN_JWT. Use a Tunnel as well where the host allows it.
Service tokens have no email, by design. The service-token JWT carries common_name (the client ID) and nothing that maps to a CMS user. The WordPress verifier and the Payload strategy both refuse to turn one into a user; the origin guard accepts it. This keeps the Worker’s credential read-only by construction: it can never act as an editor.
Forward the user’s JWT under a custom header. Cloudflare overwrites Cf-Access-* headers when a request crosses a second Access application, so a gateway that forwards the editor’s token must use another name. X-Access-User-Jwt matches the existing MCP Worker, and accessRequestHeaders strips any caller-supplied copy before setting it.
Bind forwarded JWTs to the gateway. An Access JWT is a bearer token. The optional proof (HMAC over a timestamp and the token’s SHA-256, ±300 seconds) means a leaked JWT alone cannot be replayed at the CMS. The TypeScript and PHP implementations share a known-answer vector so they cannot drift.
Do not follow redirects to an Access origin. When Access rejects a service token it can answer with a redirect to its login page, and a fetch that follows it returns a 200 HTML login page that looks like success. accessFetch sets redirect: "manual", and isAccessRejection recognises both the 401/403 and the login redirect.
Provisioning goes through the API. Wrangler has no Access commands, and the repo bans Wrangler config anyway. provisionLockdown creates or updates the application, reusable policies and service tokens by name, so it is safe to re-run. A service token’s secret is returned only on creation; on a re-run the script says so and the token has to be rotated to get a new secret. The API token needs Access: Apps and Policies Edit and Access: Service Tokens Edit. --dry-run prints the writes without sending them.
WordPress loopbacks break behind the origin guard. wp-cron.php over HTTP and Site Health’s loopback checks call the public hostname and are stopped by Access. Set DISABLE_WP_CRON and run cron from the system scheduler; WP-CLI and other command-line runs are exempt from the guard.
Payload stays one app with path rules. Give the Access application two destinations, cms.example.org/admin and cms.example.org/api, and leave the rest public. Add accessAuthStrategy() to the users collection’s auth.strategies; the admin’s own browser calls to /api then carry the editor’s assertion. Consider disableLocalStrategy on that collection so Access is the only way in. Front-end pages read through the Local API, which skips access control unless a call passes overrideAccess: false; the demo should pass it so the public site cannot read drafts by accident. Whether the strategy works unchanged under vinext is part of spike 4.
What changed from the MCP plugin’s mu-plugin
- Renamed to
patronage-access: constantsPATRONAGE_ACCESS_*, actionpatronage_access_authenticated, error codespatronage_access_*, response headersX-Access-WordPress-User-IDandX-Access-WordPress-User-Slug. The MCP Worker will need the same renames when it moves intoapps/mcp. - New origin guard (
PATRONAGE_ACCESS_REQUIRE_ORIGIN_JWT,PATRONAGE_ACCESS_ORIGIN_AUD), with the verifier taking arequire_emailflag so service tokens pass the guard but never authenticate as users. - The verification-and-refresh path is shared by the guard and the REST/WPGraphQL authenticator.
Follow-ups
- Live run (done, 2026-10-30). Three Access applications: the WordPress CMS hostname; the front end’s
/previewand/api/previewpaths on both its custom domain and its workers.dev host; and Payload’s/adminand/api. The front end reads published content and media with a service token (WORDPRESS_ACCESS_CLIENT_ID,WORDPRESS_ACCESS_CLIENT_SECRET) and signs forwarded editor JWTs withWORDPRESS_ACCESS_PROOF_SECRET, all set as Worker secrets withcf. Results:- Public pages and media load. Resized media first failed with Images error
9432until Cloudflare Images was enabled on the account’s plan; check that before a first deploy. - An editor’s preview of a draft works.
- A direct request to the PHP origin without Access gets 403.
- Signed-out requests to wp-admin and the Payload admin and API redirect to the Access login.
- A forged JWT or proof sent with a valid service token gets 401.
- WP cron runs from the system scheduler with
DISABLE_WP_CRON, as the loopback finding above predicts.
- Public pages and media load. Resized media first failed with Images error
- Static uploads. The origin guard runs in PHP, so files the web server serves directly from
wp-content/uploadsskip it. Close that at the host, or with a Tunnel where the host allows one. - wp-admin SSO. Logging editors into wp-admin from their Access JWT (open as a PR in the old WordPress repo) belongs in this plugin next, so editors do not log in twice.
- Payload against a real instance, once spike 4 settles where Payload runs.
- Docs-site pages: one per layer in the table above.