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.
How It Works
Section titled “How It Works”- During enrollment, the API calls Cloudflare’s Client Certificates API to issue a certificate
- The agent receives the certificate and private key, stores them alongside its config
- The agent uses the certificate for all HTTPS and WebSocket connections
- Cloudflare’s WAF enforces that only requests with valid client certificates reach your origin
- Certificates auto-renew at 2/3 lifetime via the heartbeat cycle
-
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
-
Get your Zone ID
Cloudflare Dashboard → your domain → Overview → Zone ID (right sidebar).
-
Configure environment variables
Add to
.env.prod:Terminal window CLOUDFLARE_API_TOKEN=your-cf-api-tokenCLOUDFLARE_ZONE_ID=your-zone-id -
Run the database migration
Terminal window pnpm db:migrateThis adds mTLS columns to the
devicestable:mtlsCertSerialNumbermtlsCertExpiresAtmtlsCertIssuedAtmtlsCertCfIdquarantinedAt/quarantinedReason
-
Restart the API
Terminal window docker compose -f docker/docker-compose.prod.yml restart api -
Enroll new agents
New enrollments will automatically receive mTLS certificates. The enrollment response includes an
mtlsobject with the certificate and private key. -
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.prodand CI-enforced bypnpm check:agent-mtls-edge-policy, so never widen it to acontainsor trailing-wildcard match:Rule name: Require mTLS for the protected agent route setExpression:(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_verifiedAction: BlockThe 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>/approveand friends), which are browser/user-JWT routes with no client certificate. The fourth pattern covers extensions that declareagentRoutes: 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.
Edge Assertion Contract
Section titled “Edge Assertion Contract”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-VerifiedandX-Breeze-Client-Cert-Serialwithrequest_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-knownendpoints each proxy to the sameapi:3001through their ownhandleblocks, 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:
off(default) — the assertion is never consulted.audit— the binding decision is computed and counted, never denies. Use this to measure mismatch/missing rates before enforcing.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.
Certificate Lifecycle
Section titled “Certificate Lifecycle”| 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 |
Quarantine
Section titled “Quarantine”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:
# List quarantined devicescurl -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/denyOrganization Settings
Section titled “Organization Settings”Enable or configure mTLS per organization:
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/mtlsQuarantine policies:
auto— Automatically quarantine devices with expired or invalid certificatesmanual— Only quarantine via admin actiondisabled— mTLS tracking without enforcement