Skip to content

Cloudflare mTLS

Breeze optionally integrates with Cloudflare API Shield to issue mTLS client certificates to agents during enrollment. This provides zero-trust authentication where both the server and agent verify each other’s identity.

  1. During enrollment, the API calls Cloudflare’s Client Certificates API to issue a certificate
  2. The agent receives the certificate and private key, stores them alongside its config
  3. The agent uses the certificate for all HTTPS and WebSocket connections
  4. Cloudflare’s WAF enforces that only requests with valid client certificates reach your origin
  5. Certificates auto-renew at 2/3 lifetime via the heartbeat cycle
  1. Create a Cloudflare API token

    Go to Cloudflare Dashboard → My Profile → API Tokens → Create Token:

    • Permission: Zone → SSL and Certificates → Edit
    • Zone Resources: your domain’s zone
  2. Get your Zone ID

    Cloudflare Dashboard → your domain → Overview → Zone ID (right sidebar).

  3. Configure environment variables

    Add to .env.prod:

    Terminal window
    CLOUDFLARE_API_TOKEN=your-cf-api-token
    CLOUDFLARE_ZONE_ID=your-zone-id
  4. Run the database migration

    Terminal window
    pnpm db:migrate

    This adds mTLS columns to the devices table:

    • mtlsCertSerialNumber
    • mtlsCertExpiresAt
    • mtlsCertIssuedAt
    • mtlsCertCfId
    • quarantinedAt / quarantinedReason
  5. Restart the API

    Terminal window
    docker compose -f docker/docker-compose.prod.yml restart api
  6. Enroll new agents

    New enrollments will automatically receive mTLS certificates. The enrollment response includes an mtls object with the certificate and private key.

  7. Configure Cloudflare WAF rules

    Once all agents have certificates, add a WAF rule to enforce mTLS on the exact protected route set — REST agent identity, renewal confirmation, and the command WebSocket. This same expression is mirrored verbatim in docker/Caddyfile.prod and CI-enforced by pnpm check:agent-mtls-edge-policy, so never widen it to a contains or trailing-wildcard match:

    Rule name: Require mTLS for the protected agent route set
    Expression:
    (
    http.request.uri.path matches "^/api/v1/agents/[0-9a-fA-F]{64}(?:/.*)?$"
    or http.request.uri.path eq "/api/v1/agents/renew-cert/confirm"
    or http.request.uri.path matches "^/api/v1/agent-ws/[0-9a-fA-F]{64}/ws$"
    or http.request.uri.path matches "^/api/v1/(?:ext/)?[a-z0-9][a-z0-9-]*/agent/[0-9a-fA-F]{64}(?:/.*)?$"
    )
    and http.request.uri.path not in {
    "/api/v1/agents/enroll"
    "/api/v1/agents/renew-cert"
    "/api/v1/agents/renew-cert/challenge"
    }
    and not cf.tls_client_auth.cert_verified
    Action: Block

    The identity segment is 64 hex characters, not a UUID: the agent ID is randomBytes(32).toString('hex'). A UUID-shaped {36} pattern matches no agent route at all while wrongly matching the 36-character UUID admin routes (/api/v1/agents/<deviceId>/approve and friends), which are browser/user-JWT routes with no client certificate. The fourth pattern covers extensions that declare agentRoutes: true, which mount a second agent-token surface at /api/v1/ext/<extension>/agent/<agentId> and /api/v1/<routeNamespace>/agent/<agentId>.

    The renewal confirmation route (/renew-cert/confirm) is deliberately protected, not exempted — it proves possession of the newly issued identity. Only the three bearer-only endpoints above (enrollment, renewal request, renewal challenge) are exempt, and each is an exact path, never a substring.

Cloudflare’s WAF rule above blocks unverified traffic at the edge, but the API still needs to know which device presented the certificate — that per-device match is a separate, API-layer decision (AGENT_MTLS_BINDING_MODE). The API reads exactly two internal headers and nothing else:

Header Meaning
X-Breeze-Client-Cert-Verified true only when the request’s mTLS handshake was verified by the trusted edge
X-Breeze-Client-Cert-Serial The verified certificate’s serial number, uppercase hex, no separators

These are trusted only when the request arrives from a configured trusted proxy — the API never reads raw Cloudflare headers (Cf-Client-Cert-*) directly. The bundled docker/Caddyfile.prod normalizes this at the last hop before the API in two distinct places, and the split is load-bearing:

  • Globally, before any routing, the site block deletes inbound X-Breeze-Client-Cert-Verified and X-Breeze-Client-Cert-Serial with request_header -.... This covers every route that reaches the API origin, not just the main /api/* one — /api/v1/mcp/sse, /api/v1/ai/sessions/*/stream, /api/v1/helper/chat/sessions/*/messages, /oauth/* and the OAuth .well-known endpoints each proxy to the same api:3001 through their own handle blocks, and a per-route strip would have to be remembered for each new one. Stripping once, globally, makes the safe state the default for future routes as well.
  • Per route, at the proxy hop, Cloudflare’s raw certificate material (Cf-Client-Cert-*, including PEM/DER, which must never reach the API) is discarded, and — only in the /api/* block — the two Breeze headers are set from a verified Cloudflare result via a strict allowlist map. The serial is gated on that same verified condition, not just the verified flag, so an unverified or spoofed result can never carry a real-looking serial number downstream.

The set and the delete must never live in the same reverse_proxy block: Caddy compiles a reverse_proxy’s header_up lines into a single header operation and applies deletes after sets, regardless of the order they were written, so a co-located header_up -X-Breeze-... silently erases the value the neighbouring header_up X-Breeze-... {placeholder} just produced and the binding layer goes inert with no error anywhere. scripts/check-agent-mtls-edge-policy.sh rejects that shape. Full detail: docs/operations/cloudflare-mtls-setup.md (including a spoofing-resistance test you can run against your own deployment).

Roll out enforcement in three steps, each requiring an explicit operator change to AGENT_MTLS_BINDING_MODE — self-hosted deployments stay off by default and see no behavior change:

  1. off (default) — the assertion is never consulted.
  2. audit — the binding decision is computed and counted, never denies. Use this to measure mismatch/missing rates before enforcing.
  3. enforce — a device with an active stored certificate must present a verified, matching assertion or the request is denied. A device with no certificate history at all (legacy, pre-mTLS) remains allowed, so mixed-version fleets do not break.

AGENT_MTLS_BINDING_MODE is mapped explicitly into the API service in both docker-compose.yml and deploy/docker-compose.prod.yml, defaulted to off (${AGENT_MTLS_BINDING_MODE:-off}) — set it in your .env file to change it. It is never inferred from NODE_ENV, IS_HOSTED, or the CF_MTLS_* issuance variables; the operator always selects the mode explicitly.

Event Trigger Action
Issuance Agent enrollment API calls CF API, returns cert in enrollment response
Renewal Heartbeat detects 2/3 lifetime reached API signals renewCert: true, agent calls /renew-cert
Revocation Admin action or quarantine API calls CF API to revoke, device marked quarantined

When a device is quarantined:

  • Its mTLS certificate is revoked via Cloudflare API
  • Device status changes to quarantined
  • The device cannot communicate with the API
  • Admin can approve or deny the device:
Terminal window
# List quarantined devices
curl -H "Authorization: Bearer $TOKEN" \
https://breeze.yourdomain.com/api/v1/agents/quarantined
# Approve a quarantined device (issues new cert)
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://breeze.yourdomain.com/api/v1/agents/:id/approve
# Deny (permanently revoke)
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://breeze.yourdomain.com/api/v1/agents/:id/deny

Enable or configure mTLS per organization:

Terminal window
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"mtls": {"enabled": true, "quarantinePolicy": "auto"}}' \
https://breeze.yourdomain.com/api/v1/org/:orgId/settings/mtls

Quarantine policies:

  • auto — Automatically quarantine devices with expired or invalid certificates
  • manual — Only quarantine via admin action
  • disabled — mTLS tracking without enforcement