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:

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.

How set, add, and remove change a header on the wire Given an origin that already emits one header, set replaces it with a single copy, add appends a second copy, and remove deletes it. Operation Origin emits Client receives set CSP: policy-A CSP: policy-B add CSP: policy-A CSP: A · CSP: B (×2) remove X-Powered-By: PHP — header removed —
Only 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:

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"
      }
    }
  }
}
Transform Rule match and modify pipeline at the edge An origin response enters the edge, the rule expression matches, header operations apply, and the rewritten response is returned to the client. Origin response Expression match? set / add / remove Rewritten to client yes no match: response passes through unchanged
The rule evaluates its expression against each response; on a match it applies the header operations, otherwise the response passes through unchanged.

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

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.

Deciding between a Transform Rule and a Worker If the header value depends on the response body use a Worker, otherwise a Transform Rule with either a static or a request-derived value. Does the value depend on the response body? (nonce, content hash) yes Cloudflare Worker no Same value for every matched response? yes request-derived Transform Rule set · static value Transform Rule dynamic wirefilter expression
Reach for a Worker only when the header value is entangled with the response body; every static or request-derived security header stays a cheaper, non-destructive Transform Rule.

Safe rollback. Transform Rules are non-destructive: disabling reverts instantly to origin headers with no propagation delay.

  1. Disable: PATCH /zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id} with {"enabled": false}.
  2. Or delete: DELETE /zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}.
  3. Verify origin fallback headers with the cache-bypass curl above.

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.