cf-access-mock
v0.1.0
Published
Local mock of Cloudflare Access for offline development: serves a JWKS at /cdn-cgi/access/certs and mints RS256 CF_Authorization JWTs. No WARP, no internet, no real Cloudflare needed.
Maintainers
Readme
cf-access-mock
Local mock of Cloudflare Access for offline development.
It serves a JWKS at Cloudflare's literal /cdn-cgi/access/certs path and mints RS256 CF_Authorization JWTs with the exact claim shape of a real Access application token — so your app's existing JWT verification (signature via JWKS, iss, aud, email) passes unchanged, with no WARP, no VPN, no internet, and no real Cloudflare involved.
The problem
Apps deployed behind Cloudflare Access validate the Cf-Access-Jwt-Assertion header / CF_Authorization cookie against https://<team>.cloudflareaccess.com/cdn-cgi/access/certs.
For local development the usual workaround is copying CF_Authorization out of a real, logged-in staging session in devtools — which dies whenever the token expires, staging is down, the VPN/WARP client is broken, or you're offline.
cf-access-mock replaces the Cloudflare side entirely: your own local issuer, your own keys, tokens for any email you ask for.
Requirements
- Node.js ≥ 18.17 (native
fetchandjosev6). opensslon PATH — impersonate mode only, to generate the local CA and leaf certificate on first run. Preinstalled on macOS (LibreSSL works fine) and virtually every Linux distro; the defaulthttpmode never touches it.- macOS or Linux for impersonate mode (it reads
/etc/hostsand binds:443; on Linux the bind needs a one-timesetcap, see below). The defaulthttpmode has no OS-specific behavior. Windows is untested. sudoonce for the impersonate-mode/etc/hostsentry — the tool never runs sudo itself, it prints the one-liner for you.
Quick start
npm install -g cf-access-mock
cf-access-mock --email [email protected]cf-access-mock is running
issuer http://localhost:8788
Point your app's issuer/JWKS override at the mock (.env.local):
CF_ACCESS_ISSUER_URL_OVERRIDE=http://localhost:8788
CF_ACCESS_AUDIENCE=b0d0...64-hex...c4f1
CF_Authorization ([email protected], expires in 6h):
eyJhbGciOiJSUzI1NiIsImtpZCI6...
Browser options:
a) open http://localhost:8788/[email protected] <- sets the cookie, no extension needed
b) ModHeader: cf-access-jwt-assertion = <token above>
c) console: document.cookie = "CF_Authorization=<token above>; path=/; max-age=21600"
Fresh token any time: curl 'http://localhost:8788/[email protected]'The /login trick works because cookies are host-scoped but port-blind (RFC 6265): a cookie set from localhost:8788 is also sent to your app on localhost:3000.
Two integration shapes
1. Your app can point its issuer/JWKS URL at anything → default http mode
If your app builds its verification from a configurable issuer or JWKS URL, just point it at the mock.
jose's createRemoteJWKSet (and most JWKS clients) happily fetch plain http:// URLs, so no TLS is involved.
A typical consumer, unchanged from what you'd run in production:
import { createRemoteJWKSet, jwtVerify } from "jose";
const ISSUER = process.env.CF_ACCESS_ISSUER_URL_OVERRIDE ?? `https://${process.env.CF_ACCESS_TEAM_DOMAIN}.cloudflareaccess.com`;
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/cdn-cgi/access/certs`));
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: process.env.CF_ACCESS_AUDIENCE,
});2. Your app hardcodes https://<team>.cloudflareaccess.com → impersonate mode
Many codebases (reasonably) refuse non-Cloudflare issuers and build the URL from a bare team name. For those, the mock can become a fake team:
cf-access-mock --mode impersonate --team local-mock --email [email protected]This serves the same issuer over https on port 443 for the hostname local-mock.cloudflareaccess.com, using a locally generated CA. Two one-time steps, both printed by the tool:
# 1. Resolve the fake team locally (the only sudo ever needed; the name is fake, so
# the entry never conflicts with your real team and can stay forever):
echo "127.0.0.1 local-mock.cloudflareaccess.com" | sudo tee -a /etc/hosts
# 2. Make Node processes trust the mock CA (Node ignores the OS keychain;
# set it in the shell that starts each service):
export NODE_EXTRA_CA_CERTS="$HOME/.cf-access-mock/ca.pem"Then configure the app exactly like production, no code changes:
CF_ACCESS_TEAM_DOMAIN=local-mock
CF_ACCESS_AUDIENCE=<printed by the tool>Port 443 is required because apps build the certs URL without a port. On macOS this needs no sudo (unprivileged processes may bind low ports on the wildcard address); on Linux grant the capability once: sudo setcap cap_net_bind_service=+ep "$(command -v node)".
On every impersonate-mode start the tool self-checks the setup end to end, three layers with a ✓/✗ line each and the exact fix on failure:
- DNS — the fake hostname must resolve to this machine through the system resolver (without the hosts entry,
*.cloudflareaccess.comresolves to real Cloudflare — which later fails as a confusing signature/kid mismatch, not a DNS error). Includes a DNS-flush hint when/etc/hostshas the entry but it hasn't taken effect yet. - Issuer — the JWKS must answer over
https:443with the mock CA pinned. - CA trust — the same request without pinning, through Node's default trust store: it succeeds only if
NODE_EXTRA_CA_CERTSwas exported before the process started. This catches "exported in the wrong shell", "set after startup", and "points at the wrong file" (each gets its own message). It reflects the shell the mock was started from — start it from the same environment your services use (a global export in your shell profile makes this a non-issue).
First-run checklist (impersonate mode)
- Start the mock — if your app doesn't run on port 3000, set the login redirect target now:
cf-access-mock --mode impersonate --email [email protected] --redirect http://localhost:3100 - One-time: hosts entry. If the startup report says
Self-check: FAILED, run the printed sudo one-liner and restart the mock until you seeSelf-check: OK. The entry is permanent and harmless — the team name is fake, so it never collides with a real Cloudflare team. - One-time: trust the CA in every shell that starts a JWT-verifying service, before starting it — Node reads this at process start, so putting it in a runtime-loaded
.envfile does nothing:
Add it to the same place you keep per-project shell setup (direnv, profile, Makefile) — a permanent global export inexport NODE_EXTRA_CA_CERTS="$HOME/.cf-access-mock/ca.pem"~/.zshrc/~/.bashrcis safe, it only adds one CA for Node processes. The startup self-check verifies this took effect (✓ CA trust); if it prints✗, fix the export and restart the mock from a shell that has it. - Point your services at the mock with the two values printed at startup, then restart them:
CF_ACCESS_TEAM_DOMAIN=local-mock CF_ACCESS_AUDIENCE=<printed by the tool> - Authentication is not authorization. The mock signs any email you ask for, but if your app maps emails to roles in its own database, seed the minted email there too — otherwise you'll be authenticated and still hit the app's "no access" state.
- Log the browser in:
The cookie is set foropen "http://localhost:8788/[email protected]"localhost(ports don't matter for cookies), and you land on your app authenticated. Alternatives: a header-injection extension (cf-access-jwt-assertion: <token>) ordocument.cookiein the console — both printed at startup. - Verify independently (optional):
curl --cacert ~/.cf-access-mock/ca.pem https://local-mock.cloudflareaccess.com/cdn-cgi/access/certs
Endpoints
| Endpoint | Purpose |
| --- | --- |
| GET /cdn-cgi/access/certs | JWKS — same path as real Cloudflare Access |
| GET /mint?email=<addr>&ttl=<30m\|6h\|1d> | Returns a fresh signed JWT as plain text |
| GET /login?email=<addr>&redirect=<url> | Sets the CF_Authorization cookie and redirects (default http://localhost:3000) |
| GET / | Service info |
Minted claims
{
"aud": ["<your audience>"],
"email": "[email protected]",
"iss": "http://localhost:8788",
"type": "app",
"identity_nonce": "6ei69kawdKzMIAPF",
"sub": "7335d417-61da-459d-899c-0a01c76a2f94",
"country": "US",
"exp": 1659474457,
"iat": 1659474397,
"nbf": 1659474397
}Fidelity notes: aud is an array (as in real Access tokens), iss has no trailing slash, type is "app", and the JWT header carries kid + RS256.
The JWKS response serves the standard keys array; Cloudflare's extra public_cert/public_certs PEM fields are not replicated (standards-based validators, including jose, read only keys).
Flags
| Flag | Default | Purpose |
| --- | --- | --- |
| --email <addr> | [email protected] | email claim of minted tokens |
| --audience <str> | generated once, persisted | aud claim; print-matched to CF_ACCESS_AUDIENCE |
| --ttl <dur> | 6h | token lifetime (30m, 6h, 1d) — your choice, unlike a real session |
| --mode <http\|impersonate> | http | see integration shapes above |
| --team <name> | local-mock | fake team name (impersonate mode) |
| --port <n> | 8788 | plain-http listener |
| --redirect <url> | http://localhost:3000 | default /login redirect target |
| --rotate-keys | — | replace the persisted signing keypair |
State
Everything persists in ~/.cf-access-mock/: the RS256 signing key (so tokens survive restarts and consumers' JWKS caches stay valid), the generated audience, and the impersonation CA.
Delete the directory or run --rotate-keys to start fresh.
Security
This tool authenticates nobody — it mints a valid-looking token for any email it is asked for. That is the point, locally.
- Never expose it beyond localhost.
- Never let a deployed environment resolve or trust it: keep
NODE_EXTRA_CA_CERTSand the fake team name out of production config. (A deployed app pointed at a fake team fails closed anyway — the real*.cloudflareaccess.comresolves to Cloudflare, which won't serve your mock's keys.) - Treat
~/.cf-access-mock/as dev-machine material; the keys sign identity tokens your local apps trust.
This project is not affiliated with or endorsed by Cloudflare. "Cloudflare" is a trademark of Cloudflare, Inc., used here only to describe compatibility.
