Skip to content

Cloudflare Access JWT trust

When you put Breeze behind Cloudflare Access (or any Zero Trust gateway that mints CF Access JWTs) and use the same identity provider that you’ve configured users for in Breeze, the user ends up authenticating twice: once at the CF Access edge, once at the Breeze login form. Enabling Cloudflare Access JWT trust lets a valid Cf-Access-Jwt-Assertion header short-circuit POST /api/v1/auth/login and mint a Breeze session directly.

This is an opt-in, deployment-wide flag. It does not affect any deployment that does not set CF_ACCESS_TRUST_ENABLED=true.

  1. The browser hits the Breeze SPA. Cloudflare Access authenticates the user against your IdP and attaches a signed CF_Authorization cookie plus the Cf-Access-Jwt-Assertion request header.
  2. The SPA POSTs /api/v1/auth/login. The CF Access middleware runs first.
  3. The middleware verifies the JWT using the team’s JWKS at https://<team-domain>/cdn-cgi/access/certs. It checks:
    • signature (RS256 only)
    • issuer = https://<team-domain>
    • audience = the configured AUD tag for your application
    • exp, iat, email, aud, iss, sub claims all present
  4. On a verified JWT, it looks up the user by email claim, confirms the account is active, resolves the user’s partner/org context, mints a token pair, and returns the same shape POST /login returns on a successful password login.
  5. On any failure — flag off, header absent, invalid signature, expired token, wrong audience, JWKS unreachable, user not in Breeze, user inactive — it falls through to the existing password handler. The browser then sees the normal login form.
FailureBehaviourReason
CF_ACCESS_TRUST_ENABLED=false or unsetNo JWT path; password handler runs as beforeDefault off
Header absentFall through to passwordNot every browser session has a CF Access JWT
Invalid signature, wrong issuer/aud, expired, missing claimFall through; logged as [cf-access-login] rejected JWT with the jose error codeFail-closed on trust: we never mint a session from an unverified JWT
JWKS fetch / network failureFall through; logged as [cf-access-login] JWKS unavailable...Fail-open on availability: a transient CF outage shouldn’t wedge /login
User from JWT email not present in BreezeFall throughPassword handler will 401 with the same generic error, no email enumeration
User present but status != 'active'Fall through; failure audited as account_inactive with method: cf_access_jwtSame denial path as password login

The Cloudflare Access JWT does not carry an MFA claim — it tells you the user satisfied your CF Access policy, but not how. Whether your CF Access policy required step-up (hardware key, WARP attestation, etc.) is an operator-level assertion. CF_ACCESS_TRUSTS_MFA is the knob:

  • CF_ACCESS_TRUSTS_MFA=false (default) — even on a valid JWT, if the matched Breeze user has MFA enrolled, the middleware issues a tempToken and the SPA’s normal MFA challenge runs. The user does not have to re-enter their password, but they do enter their TOTP code.
  • CF_ACCESS_TRUSTS_MFA=true — the minted Breeze session is marked as MFA-satisfied. Only turn this on if your CF Access policy actually requires step-up. The setting is honoured deployment-wide; it does not vary per partner or per user.

The header short-circuit on POST /api/v1/auth/login covers the normal SPA login XHR, but top-level browser navigations (deep links, an SSO-style redirect into Breeze) can’t attach a Bearer token or read a JSON response. For that case there is a dedicated GET /api/v1/auth/cf-access-login endpoint:

  1. A top-level navigation hits GET /api/v1/auth/cf-access-login?next=/dashboard. Cloudflare Access attaches the Cf-Access-Jwt-Assertion header automatically.
  2. The handler verifies the JWT (same signature/issuer/audience checks as the middleware), looks up the user by email, confirms the account is active, and resolves the partner/org context.
  3. On success it mints a Breeze token pair, sets the refresh cookie, and issues a 302 to the sanitized next path with a ?cf-access-login=success marker. The SPA detects the marker and bootstraps from the refresh cookie.
  4. On any failure it redirects to /login?error=cf-access&reason=… (e.g. invalid-jwt, no-user, mfa-required) so the user falls back to the normal login form.

The next target is sanitized to a single leading-slash relative path, so the endpoint cannot be used as an open redirect.

CF Access-minted sessions behave exactly like password-login sessions for the full token lifecycle — there is no weaker “edge-trusted” session class.

  • Logout revokes everything. GET /api/v1/auth/cf-access-logout reads the refresh cookie, verifies it, and then calls the same bulk revocation as password logout: it revokes all of the user’s access and refresh tokens and the specific refresh-token JTI, then clears the cookie. An access or refresh token exfiltrated before sign-out is dead the moment the user signs out, rather than living to its natural expiry.
  • Refresh-family reuse detection. Both CF Access mint paths (the POST /login header short-circuit and the GET /cf-access-login redirect) issue refresh tokens inside a bound token family, identical to password login. Replaying a previously rotated refresh token trips reuse detection and revokes the whole family. Before this parity fix, CF-minted tokens skipped family binding and were exempt from reuse detection.
  • Origin-validated logout redirect. The post-logout redirect target is built from the configured DASHBOARD_URL / PUBLIC_APP_URL origin, never from the request Host header, and falls back to an https-only scheme. A spoofed Host cannot bounce the user off-domain after Cloudflare clears its session.
VariableRequired when trust is onDescription
CF_ACCESS_TRUST_ENABLEDtrue to enable. Boolean (true/false/1/0/yes/no/on/off). Default off.
CF_ACCESS_TEAM_DOMAINYesBare hostname of your Cloudflare team domain, e.g. example.cloudflareaccess.com. No https:// scheme.
CF_ACCESS_AUDYesThe AUD tag for the Cloudflare Access application that protects Breeze. Get it from the Cloudflare Zero Trust dashboard → Access → Applications → your app → AUD.
CF_ACCESS_TRUSTS_MFABoolean. Default false. See MFA above.

The config validator refuses to boot if CF_ACCESS_TRUST_ENABLED=true but CF_ACCESS_TEAM_DOMAIN or CF_ACCESS_AUD is empty, or if the team domain looks like a URL instead of a hostname.

  • Application path: cover the SPA root, /api/v1/auth/login, /api/v1/auth/cf-access-login, and /api/v1/auth/cf-access-logout. Leave bypass rules on /api/* agent paths, /health, /installers/*, and any installer short-links (the agent fleet does not have a CF Access session). If you use a blanket bypass rule on /api/*, make sure it does not swallow /api/v1/auth/cf-access-login or /api/v1/auth/cf-access-logout — those two endpoints must be enforced by the Access application (more-specific paths win), otherwise the JWT never reaches the redirect login handler and sign-out cannot clear the CF Access session.
  • Identity provider: pick the same IdP whose email claim is the same one your Breeze users are provisioned with. If a Breeze user’s email is alice@acme.com and your IdP issues the JWT with email=alice@acme.com, you’re set.
  • Session duration: anything you like. Breeze mints its own refresh token independently of the CF Access cookie.
  • Application AUD: copy from the dashboard once the application is created. This is stable for the life of the application.

Set CF_ACCESS_TRUST_ENABLED=false (or remove the variable) and restart breeze-api. The middleware short-circuits to next() before reading any other env var, so disabling is instant.