Docs

Access — the admin gate

In deployed environments the admin surface is gated by Cloudflare Access at the edge, and re-verified in-app as defense in depth (src/auth/access.ts). The reader surface stays public. Why the boundary is drawn this way — one host, two audiences, an explicit public allowlist and everything else admin — is docs/SPEC.md §10.

There is no auth code to write. Your job is to configure one Access application correctly and set three vars.

What must be gated, and what must not

The authoring API is not a single path prefix — it is spread across several. The Access application must cover the editor and every authoring path, so the editor’s same-origin fetch calls carry the Access JWT. It must not cover the reader routes, or readers would hit a login wall.

Gate exactly these path prefixes (each match includes all subpaths):

PrefixWhat it is
/dashboardthe editor SPA (static assets)
/postsposts, revisions, images, preview, test, schedule, send
/sendsthe send status surface
/subscribersthe subscriber roster
/suppressionsthe suppression list
/apiwhoami, the in-app docs (/api/docs)

Leave everything else public — the reader surface and webhooks: / (landing page), /subscribe, /confirm, /unsubscribe, ARCHIVE_BASE_PATH (e.g. /archive/*, the archive index and post pages), /media/*, /webhooks/*, /health.

/dashboard is load-bearing: the editor SPA lives there, so if it isn’t in this application the editor ships ungated. Every authenticated route lives under one of the six prefixes above, so a new authoring endpoint added under /api (as the in-app docs are) is gated by the same application automatically. The one public /api route, /api/dev/token, exists only in a dev-shaped env and 404s once deployed, so gating /api wholesale is safe in production. Verify this against src/app.ts if the routes ever change.

1. Create one Access application

In the Cloudflare Zero Trust dashboard → Access → Applications → Add an application → Self-hosted:

  • Application domain: newsletter.example.com.
  • Paths: add all six prefixes above (dashboard, posts, sends, subscribers, suppressions, api) to this single application. Do not create one application per path — one application, many paths, so they share the AUD and policy set.

Note the application’s Application Audience (AUD) tag from its settings — you need it below.

2. Add the two policies

On that application:

  • Allow (you, the human). An Allow policy with your identity provider (Google / GitHub / one-time PIN to your email). This is the interactive login.
  • Service Auth (Claude / automation). A Service Auth policy, then create a service token under Access → Service Auth. It yields a CF-Access-Client-Id and CF-Access-Client-Secret. Claude sends those two headers; the request never needs a browser handshake, and service tokens do not consume Zero Trust seats.

The in-app verifier tells the two apart: a token with an email claim is the human (checked against the optional allowlist below); a service token has no email and is authorized purely by the Service Auth policy.

3. Set the Worker vars

Set these on each deployed environment so the in-app re-verification can validate the JWT:

npx wrangler secret put ACCESS_TEAM_DOMAIN --env staging   # e.g. your-team.cloudflareaccess.com
npx wrangler secret put ACCESS_AUD --env staging           # the AUD tag from step 1
# optional: restrict human logins to specific emails (CSV)
npx wrangler secret put ACCESS_ALLOWED_EMAILS --env staging # e.g. you@example.com,team@example.com
  • ACCESS_TEAM_DOMAIN and ACCESS_AUD must both be set for in-app JWT validation to run; if either is missing, the app cannot verify Access assertions and the gate rests on the edge alone.
  • ACCESS_ALLOWED_EMAILS is optional. Empty/unset admits any valid Access login; set it to lock admin down to named humans. It gates humans only — service tokens carry no email.

4. There is no bearer backdoor to close

Local dev authenticates with a JWT signed by DEV_AUTH_SECRET (src/auth/dev_token.ts), which lives only in the gitignored .dev.vars — never in wrangler.jsonc. A deployed environment therefore has no value for it, and getConfig honors it only in a dev-shaped env (fake transport and no Access configured) anyway. So there is nothing to unset: once you set PROVIDER to a real transport and configure Access, the dev credential path is structurally off and Access is the only door. Do not add DEV_AUTH_SECRET to any deployed environment’s secrets.

5. Reach gated endpoints from a terminal

Because deployed authoring routes require an Access JWT, a plain curl gets a login-redirect, not JSON. Use cloudflared to attach your identity:

# one-off request as the interactive human (opens a browser to log in once)
cloudflared access curl https://newsletter.example.com/api/whoami

# or mint a token to reuse
cloudflared access token --app https://newsletter.example.com

For automation, send the service token headers instead:

curl https://newsletter.example.com/api/whoami \
  -H "CF-Access-Client-Id: <client-id>" \
  -H "CF-Access-Client-Secret: <client-secret>"

GET /api/whoami returning {"principal":{"kind":"human"},"auth":{"mode":"access"}} (or "kind":"service") confirms the gate and the in-app verifier agree. A 401 with no Access headers confirms the surface is closed.