Skip to content

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:

ResourcePurpose
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

Terminal window
cd infra
pulumi config set mnemose:provisionAccess true
pulumi config set mnemose:accessTeamName round-dawn-c7d7
pulumi config set mnemose:accessAllowedEmailDomain mnemose.ai
PULUMI_CONFIG_PASSPHRASE=*** pulumi up

Stack outputs to copy into Wrangler / CI:

  • accessTeamDomain → CF_ACCESS_TEAM_DOMAIN (https://<team>.cloudflareaccess.com)
  • accessApiAud → CF_ACCESS_AUD
  • accessCertsUrl → https://<team>.cloudflareaccess.com/cdn-cgi/access/certs
  • accessServiceTokenClientId / 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:

Terminal window
npx wrangler secret put DEV_AUTH_TOKEN

Custom claims

The gateway extracts two custom claims from a verified Access JWT:

ClaimRequiredUsed for
mnemose_tenant_idYes (production)Tenant scope. Never taken from x-tenant-id in production.
mnemose_roleNoFast-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:

  1. Zero Trust → Settings → Authentication → your IdP.
  2. Add SAML / OIDC attribute mnemose_tenant_id (UUID of the operator tenant).
  3. Optionally add mnemose_role.
  4. 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:

  1. Cf-Access-Jwt-Assertion request header (canonical; always forwarded by Access)
  2. Authorization: Bearer <jwt>
  3. CF_Authorization cookie (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

SurfaceStatusCodeExtra
GraphQLHTTP 200 (Yoga)extensions.code = UNAUTHENTICATEDWWW-Authenticate: Bearer ... on the response
REST /v1/*HTTP 401error.code = UNAUTHENTICATEDWWW-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 for Token verification failed / missing AUD.
  • Hit GET https://<team>.cloudflareaccess.com/cdn-cgi/access/certs and confirm both keys and public_certs rotate together.
  • Decode a live Cf-Access-Jwt-Assertion (iss, aud, mnemose_tenant_id).
  • GraphQL clients that see UNAUTHENTICATED should force a new Access login rather than retrying the same JWT.