How to Configure Cloudflare Transform Rules for Custom Security Headers
This guide is part of the Cloudflare security headers reference and shows the exact Response Header Modification syntax — dashboard, API, and Terraform — for injecting custom security headers at the edge. Transform Rules run in the http_response_headers_transform phase, after the origin forms its response but before the client receives it, which makes them the modern replacement for the deprecated Page Rules header model.
Injecting headers at the edge instead of the origin has two practical advantages. First, coverage is total: the header is added to every response Cloudflare proxies, including error pages, redirects, and responses served straight from cache that never touch the origin at all. Second, the source of truth is centralised — a single ruleset governs a whole zone, so you do not have to reconcile the header configuration of Nginx, Apache, a load balancer, and a static-hosting bucket that may all sit behind the same hostname. The trade-off is that the edge only sees the finished response envelope; anything that must be coordinated with the response body stays out of reach, and that single limitation drives most of the design decisions below.
Understanding where the phase sits in Cloudflare’s evaluation order prevents surprises. A request first passes through security and routing phases, reaches the origin (or cache), and the response then flows back out through the transform phases. By the time http_response_headers_transform runs, the status code, the Content-Type, and every origin header are already known and can be matched in the expression — but the body has been committed and is opaque to the rule engine.
Configuration Syntax & Exact Values
A Response Header Transform Rule has two parts: an expression that selects which responses to modify, and one or more header operations (set, add, remove) with literal values.
# Expression — match all HTML document responses
(http.response.content_type.media_type eq "text/html")
# Header operations
set Strict-Transport-Security max-age=63072000; includeSubDomains; preload
set Content-Security-Policy default-src 'self'; object-src 'none'; frame-ancestors 'none'
set X-Frame-Options DENY
set X-Content-Type-Options nosniff
set Referrer-Policy strict-origin-when-cross-origin
remove X-Powered-By
Annotated breakdown of each operation:
set— replaces every existing copy of the header (origin or prior rule) with this single value. This is the only correct operation for security headers: it guarantees exactly one authoritative directive on the wire.add— appends an additional copy, leaving any origin value in place. Browsers enforce the intersection of duplicateContent-Security-Policylines, soaddproduces unpredictable policy. Reserve it for genuinely multi-valued headers.remove— deletes the header. Use it to strip origin information leaks such asX-Powered-ByorServer.
The three operations differ only in how they interact with a header the origin already sent. set collapses whatever arrives to a single authoritative copy, add layers a second copy on top, and remove erases it entirely. The wire outcome is what the browser actually enforces, so choosing the wrong operation is a silent correctness bug rather than a syntax error.
set guarantees a single authoritative copy; add leaves two Content-Security-Policy headers whose intersection the browser enforces, and remove deletes the header outright.Dynamic header values. Transform Rules can build a value from a wirefilter expression instead of a static string. For example, concat("edge-", cf.colo.name) stamps the serving data centre into a custom header, or http.request.uri.path can be echoed back for debugging. These values are computed from the request and connection metadata only. Transform Rules cannot read the response body, so a value that must also be embedded in the HTML — most importantly a per-request CSP nonce — is impossible here and requires a Worker (see Edge Cases).
Choosing the expression. The expression is a wirefilter predicate evaluated per response. A few fields cover almost every security-header use case:
true— match every response in the zone. Correct for headers that are safe and useful everywhere, such asX-Content-Type-Options: nosniffandStrict-Transport-Security.http.response.content_type.media_type eq "text/html"— documents only. Appropriate forContent-Security-PolicyandX-Frame-Options, which govern how a rendered page behaves and are meaningless on an image or a font.http.host eq "app.example.com"— scope a stricter policy to one hostname while a laxer default covers the rest of the zone.starts_with(http.request.uri.path, "/api/")— target or exclude an API surface, for example to skip a document-only CSP on JSON endpoints.
Because the response headers are already formed, you can also branch on what the origin sent — not any(http.response.headers["content-security-policy"][*] == "") matches responses that already carry a CSP, letting you build a rule that only fills the gap where the origin was silent. Keep expressions as narrow as the intent and no narrower; an over-broad true on a body-governing header like CSP will apply it to downloads and images where it does nothing but cost a few bytes.
Dashboard
Navigate to Rules → Transform Rules → Modify Response Header → Create rule. Set the expression with the rule builder or the Edit expression field, then add one Set static (or Remove) row per header using the values above.
API
# 1. Find the entry-point ruleset ID for the response-header phase
curl -sS "https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/phases/http_response_headers_transform/entrypoint" \
-H "Authorization: Bearer {API_TOKEN}" | jq '.result.id'
# 2. Append a rule to that ruleset (POST adds; PUT on the ruleset overwrites all rules)
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}/rules" \
-H "Authorization: Bearer {API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"action": "rewrite",
"action_parameters": {
"headers": {
"Strict-Transport-Security": { "operation": "set", "value": "max-age=63072000; includeSubDomains; preload" },
"Content-Security-Policy": { "operation": "set", "value": "default-src '\''self'\''; object-src '\''none'\''; frame-ancestors '\''none'\''" },
"X-Frame-Options": { "operation": "set", "value": "DENY" },
"X-Content-Type-Options": { "operation": "set", "value": "nosniff" },
"Referrer-Policy": { "operation": "set", "value": "strict-origin-when-cross-origin" },
"X-Powered-By": { "operation": "remove" }
}
},
"expression": "(http.response.content_type.media_type eq \"text/html\")",
"description": "Inject hardened security headers"
}'
The '\''self'\'' escaping is required because the single quotes in CSP values collide with the shell-quoted JSON body; a dropped escape silently truncates the policy.
Terraform
resource "cloudflare_ruleset" "security_headers" {
zone_id = var.zone_id
name = "Inject hardened security headers"
kind = "zone"
phase = "http_response_headers_transform"
rules {
action = "rewrite"
expression = "(http.response.content_type.media_type eq \"text/html\")"
enabled = true
action_parameters {
headers {
name = "Strict-Transport-Security"
operation = "set"
value = "max-age=63072000; includeSubDomains; preload"
}
headers {
name = "Content-Security-Policy"
operation = "set"
value = "default-src 'self'; object-src 'none'; frame-ancestors 'none'"
}
headers {
name = "X-Frame-Options"
operation = "set"
value = "DENY"
}
headers {
name = "X-Powered-By"
operation = "remove"
}
}
}
}
Server-Side Configuration
Cloudflare
Transform Rules are the Cloudflare-native mechanism and replace the deprecated Page Rules header approach, which never supported arbitrary response-header injection. Deploy via any of the three methods above. Keep all security headers inside a single rule’s header map rather than spreading them across rules — most plans cap active rules per phase (Free and Pro zones are the tightest), and one consolidated rule is also easier to audit and roll back.
A single rule can carry many headers entries, so consolidation costs nothing in capability. Prefer the Terraform or API path for anything beyond a quick experiment: both make the rule reviewable in version control and reproducible across zones, whereas a dashboard-only rule is invisible to your infrastructure history and easy to drift. When you manage the ruleset with Terraform, treat the dashboard as read-only for that phase — a manual edit and a later terraform apply will fight, and the provider will revert your hand-made change without warning. If you must combine both, split ownership by phase so no single ruleset is edited from two places.
Origin equivalent (brief)
If you prefer to keep the source of truth at the origin, the equivalent on Nginx is add_header ... always (see Nginx Security Headers Configuration) and on Apache Header always set. Whichever layer owns the header, use the edge set operation so the edge value is authoritative and a stray origin copy cannot produce a duplicate.
Diagnostic & Verification Steps
# Bypass the edge cache and inspect injected headers
curl -sI -H 'Cache-Control: no-cache' https://example.com \
| grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy'
Expected output:
strict-transport-security: max-age=63072000; includeSubDomains; preload
content-security-policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
# Confirm exactly one copy of CSP — origin+edge duplication returns 2
curl -sI -H 'Cache-Control: no-cache' https://example.com | grep -ci '^content-security-policy:'
Expected output: 1.
# Confirm the rule ran at the edge (cf-ray present, dynamic/miss cache status)
curl -sI https://example.com | grep -iE 'cf-ray|cf-cache-status'
Expected output:
cf-ray: 8a1f2c3d4e5f6789-LHR
cf-cache-status: DYNAMIC
In browser DevTools, open the Network tab, disable cache, reload, and confirm each header appears once under the document request’s Response Headers.
Interpreting the results. The cf-cache-status value tells you whether the rule actually executed for the response you inspected. DYNAMIC and MISS mean the response was assembled at request time and the rule ran; HIT means you received a cached object that may predate the rule, so a missing header there is a caching artefact, not a rule failure — purge and retry. A missing cf-ray header means the request never reached Cloudflare at all (a stale DNS record or a hosts-file override pointing straight at the origin is the usual cause), in which case no edge rule could ever apply.
If a header is present but wrong, check for a second rule in the same phase overriding it, an origin copy that an add operation failed to replace, or a media-type expression that excluded the specific response you tested. The grep -ci count above is the fastest triage: a 2 points at duplication (switch add to set), a 0 points at either a non-matching expression or a cache hit.
Edge Cases, Security Implications & Safe Rollback
- Rule ordering. Rules in the response-header phase evaluate top-to-bottom; a later rule with
seton the same header overrides an earlier one. Consolidate into one rule to remove ambiguity, or verify the dashboard order matches intent. - Conflict with origin headers. If the origin emits
Content-Security-Policyand your rule usesadd, the client receives two copies and enforces their intersection. Always usesetto overwrite the origin value; verify with thegrep -cicommand above returning1. - CSP nonces need a Worker. Transform Rules cannot read or rewrite the response body, so a per-request nonce that must appear in both the header and the inline
<script nonce>attributes is impossible here. Use a Worker for security headers for that case. - Cached responses. A header baked into a cached object is served unchanged until expiry. Purge the cache (
POST /zones/{zone_id}/purge_cache) or use Development Mode, then re-test withCache-Control: no-cache. - Non-HTML responses. The example expression
media_type eq "text/html"deliberately skips JSON APIs, images, and font files.Content-Security-Policyon a JSON response is harmless but wasteful, whereasX-Content-Type-Options: nosniffis valuable on every content type. If you want a header everywhere, widen the expression totrue; if you want it only on documents, keep the media-type guard. - Header name casing and duplication across phases. A header set here can still be overwritten by a Worker running in a later phase, or by another zone-level ruleset. HTTP header names are case-insensitive, so a rule that sets
content-security-policyand an origin that sendsContent-Security-Policyare the same header —setcorrectly collapses them to one.
The single most common design decision is whether a given header even belongs in a Transform Rule at all. Anything whose value is fixed, or derivable from the request, is a Transform Rule; anything that must be woven into the response body — a nonce, a hash, a body rewrite — must move to a Worker.
Safe rollback. Transform Rules are non-destructive: disabling reverts instantly to origin headers with no propagation delay.
- Disable:
PATCH /zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}with{"enabled": false}. - Or delete:
DELETE /zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}. - Verify origin fallback headers with the cache-bypass
curlabove.
The one irreversible value is HSTS preload — once submitted to the browser preload list, removal takes months and ships in the next browser release cycle, so confirm full HTTPS coverage across every subdomain before shipping preload. The safe sequence is to run max-age at a low value such as 300 for a day, raise it to 31536000, add includeSubDomains only after verifying every subdomain answers on HTTPS, and add preload last — because includeSubDomains combined with preload forces HTTPS on hostnames you may have forgotten, and a single HTTP-only internal subdomain becomes unreachable for anyone whose browser has cached the policy.
The staging discipline matters just as much for CSP. A restrictive Content-Security-Policy that is correct in theory routinely blocks a legitimate inline script or third-party widget in practice. Ship a Content-Security-Policy-Report-Only variant first — a second set operation on that header name in the same rule — collect violation reports for a representative period, then swap the header name to the enforcing Content-Security-Policy only once the report stream is quiet. Because the Report-Only and enforcing headers are distinct names, you can even run both simultaneously: enforce a conservative baseline while report-only tests a stricter candidate.
Frequently Asked Questions
Should I use set or add for a Transform Rule header?
Use set for every security header. set replaces any existing copy so exactly one authoritative directive reaches the client. add appends a second copy, and browsers enforce the unpredictable intersection of duplicate Content-Security-Policy headers.
Can a Transform Rule generate a per-request CSP nonce?
No. Transform Rules run in the response-header phase and cannot read or rewrite the response body, so a nonce that must also appear in the HTML is impossible. Use a Cloudflare Worker for nonce-based CSP.
Why does my CSP value get truncated when I create the rule via the API?
Unescaped single quotes. CSP keywords like 'self' must be escaped as '\''self'\'' inside the shell-quoted JSON body, otherwise the shell ends the string early and the value is truncated.
Do Transform Rules replace Page Rules for headers?
Yes. Page Rules are deprecated and never supported arbitrary response-header injection. Transform Rules in the http_response_headers_transform phase are the supported mechanism for static headers.
Conclusion
Stage the rule first with a report-only CSP variant or against a non-production hostname, verify with the cache-bypass curl checks, then promote the same single consolidated rule to production using set operations. Start HSTS at a short max-age, confirm full HTTPS coverage, and only then raise it and add preload.