Cloudflare Access Zero Trust
The gateway verifies Cloudflare Access JWTs issued for the API application.
Access sits in front of api.mnemose.ai and console.mnemose.ai, stamps a
Cf-Access-Jwt-Assertion header on every request, and the Worker checks the
signature against the team JWKS.
Related implementation:
- Gateway verifier:
services/gateway/src/auth/cf-access.ts - GraphQL context:
services/gateway/src/context.ts - Pulumi resources:
infra/access.ts
What gets provisioned
infra/access.ts (enabled by mnemose:provisionAccess) creates:
| Resource | Purpose |
|---|---|
Access application mnemose-api-<env> | Protects api.mnemose.ai. AUD is CF_ACCESS_AUD. |
Access application mnemose-console-<env> | Protects console.mnemose.ai and the Pages preview host. |
Policy mnemose-operators-<env> | Allow users whose email is on the configured domain. |
Policy mnemose-service-tokens-<env> | Allow the M2M service token (non-identity). |
Service token mnemose-gateway-m2m-<env> | CI / automation credential. Client ID + secret are stack outputs. |
Pulumi
cd infrapulumi config set mnemose:provisionAccess truepulumi config set mnemose:accessTeamName round-dawn-c7d7pulumi config set mnemose:accessAllowedEmailDomain mnemose.aiPULUMI_CONFIG_PASSPHRASE=*** pulumi upStack outputs to copy into Wrangler / CI:
accessTeamDomain→CF_ACCESS_TEAM_DOMAIN(https://<team>.cloudflareaccess.com)accessApiAud→CF_ACCESS_AUDaccessCertsUrl→https://<team>.cloudflareaccess.com/cdn-cgi/access/certsaccessServiceTokenClientId/accessServiceTokenClientSecret→ store as secrets
Do not point JWKS at /cdn-cgi/access/certs.json. The documented endpoint
is /cdn-cgi/access/certs. It publishes the current and previous signing keys
so verification survives the default 6-week Access key rotation.
Worker configuration
wrangler.toml already carries team domain + AUD vars per environment:
[vars]NODE_ENV = "development"CF_ACCESS_TEAM_DOMAIN = "https://round-dawn-c7d7.cloudflareaccess.com"CF_ACCESS_AUD = "<accessApiAud from pulumi stack output>"Production and staging set NODE_ENV = "production", which hard-disables the
DEV_AUTH_TOKEN / DEV_AUTH_BYPASS shared-secret path.
Set the optional non-production secret only for local / preview:
npx wrangler secret put DEV_AUTH_TOKENCustom claims
The gateway extracts two custom claims from a verified Access JWT:
| Claim | Required | Used for |
|---|---|---|
mnemose_tenant_id | Yes (production) | Tenant scope. Never taken from x-tenant-id in production. |
mnemose_role | No | Fast-path role for auth-guard (platform_admin, tenant_admin, operator, viewer). |
Configure the IdP (OIDC or SAML) that Access uses so those attributes are present on the identity, then map them onto the Access application token:
- Zero Trust → Settings → Authentication → your IdP.
- Add SAML / OIDC attribute
mnemose_tenant_id(UUID of the operator tenant). - Optionally add
mnemose_role. - Confirm a login and decode
Cf-Access-Jwt-Assertion— both claims must appear in the payload.
Until the claim is present, production GraphQL / REST requests fail with
UNAUTHENTICATED / No tenant context.
Token sources the gateway accepts
In order:
Cf-Access-Jwt-Assertionrequest header (canonical; always forwarded by Access)Authorization: Bearer <jwt>CF_Authorizationcookie (browser requests; not guaranteed)
Service-token callers (CI, installers) send Access client credentials so Access mints a JWT; the Worker still verifies that JWT, not the client secret itself.
Dev shared-secret bypass
Allowed only when NODE_ENV !== "production" and a DEV_AUTH_TOKEN is
configured (or DEV_AUTH_BYPASS=true plus a matching token). The bearer value
must match the secret and x-tenant-id must be a valid UUID. Production
ignores both flags even if they are set.
The console mirrors this with VITE_DEV_AUTH_BYPASS / VITE_DEV_AUTH_TOKEN.
Error contract
| Surface | Status | Code | Extra |
|---|---|---|---|
| GraphQL | HTTP 200 (Yoga) | extensions.code = UNAUTHENTICATED | WWW-Authenticate: Bearer ... on the response |
REST /v1/* | HTTP 401 | error.code = UNAUTHENTICATED | WWW-Authenticate: Bearer ... |
maskError in the Worker preserves UNAUTHENTICATED so the console can
trigger a token refresh. Other unexpected errors are rewritten to
INTERNAL_SERVER_ERROR.
Observe in flight
wrangler tail— look forToken verification failed/ missing AUD.- Hit
GET https://<team>.cloudflareaccess.com/cdn-cgi/access/certsand confirm bothkeysandpublic_certsrotate together. - Decode a live
Cf-Access-Jwt-Assertion(iss,aud,mnemose_tenant_id). - GraphQL clients that see
UNAUTHENTICATEDshould force a new Access login rather than retrying the same JWT.