Cloudflare Security Headers at the Edge: Transform Rules, Workers, and Managed Transforms

This guide is part of the Server & Platform Implementation Guides reference and documents how to add, override, and strip HTTP security headers at the Cloudflare edge. The edge proxy sits between your origin and the client, so it observes — and can rewrite — every response after the origin has emitted it. That position is the source of both Cloudflare’s power and its most common misconfiguration: duplicate headers when the edge adds a directive the origin already set. Three mechanisms are in scope here — Response Header Transform Rules, Workers, and Managed Transforms — and choosing the wrong one for the job is where most edge header bugs originate.

Page Rules are deprecated for this purpose. They never supported arbitrary response-header injection; the only header-adjacent action they exposed was Always Use HTTPS. New work uses the Rules engine: static and dynamic headers belong in Cloudflare Transform Rules for custom security headers, while per-request logic such as a CSP nonce belongs in Cloudflare Workers for security headers.

Threat Model & Protocol Mechanics

Cloudflare’s edge runs after the origin in the response path. The origin generates a response, Cloudflare receives it at the nearest data center, and only then does the response-header transform phase run before the bytes reach the browser. This ordering is the entire mental model: anything the edge does is a rewrite of an already-formed response. The edge can set (replace), add (append a second copy), or remove a header. It cannot read the response body in Transform Rules, which is why body-dependent values — most importantly a per-request Content-Security-Policy nonce that must also appear in the HTML — require a Worker.

The headers managed here neutralize the standard client-side attack classes. Strict-Transport-Security (HSTS) defeats TLS-stripping and protocol-downgrade MITM by locking the browser to HTTPS. Content-Security-Policy (CSP) constrains script and resource origins to mitigate XSS and data exfiltration. X-Frame-Options and CSP frame-ancestors (frame controls) block clickjacking. X-Content-Type-Options: nosniff stops MIME confusion. Referrer-Policy and Permissions-Policy (privacy controls) curb referrer leakage and browser-feature abuse. Enforcing these at the edge moves policy out of application code, so a single origin bug or framework default cannot silently drop the protection.

Interaction with origin headers — the duplication trap. Browsers do not merge two Strict-Transport-Security lines into one coherent policy. When both the origin and the edge emit a header, the result depends on the operation:

The single most important rule on this page: use set, not add, for security headers. Reserve add for headers that are legitimately multi-valued (e.g. Set-Cookie).

Cloudflare edge position and header mechanism selection Request flows from browser through the Cloudflare edge to the origin and back; the response-header phase at the edge picks between Managed Transform, Transform Rule, or Worker. Browser Cloudflare edge Origin request origin response rewritten response Response-header phase Managed Transform (built-in) Transform Rule (static / dynamic) Worker (per-request, body-aware)
The edge rewrites the origin response after it is formed; the response-header phase selects Managed Transform, Transform Rule, or Worker.

Directive Syntax & Spec

The three edge mechanisms differ in capability, not in the headers they can emit. Choose by what the header value depends on.

Mechanism Scope Header value source Reads body? When to use
Managed Transforms Toggle, whole zone Cloudflare-curated fixed values No One-click X-Content-Type-Options: nosniff, sensitive-header removal; fastest baseline
Response Header Transform Rules Expression-matched routes Static string, or dynamic via wirefilter fields No Static CSP/HSTS/X-Frame-Options, route-conditional headers, stripping origin leaks
Workers Any request, programmatic Arbitrary JS, including per-request random values Yes (can rewrite HTML) Per-request CSP nonces, hash computation, header logic that needs the body

The selection collapses to a single question: what does the header value depend on? If the value is a fixed baseline, the built-in Managed Transform is fastest and needs no configuration. If it is a static string or varies only by route, a Transform Rule matched with a wirefilter expression is the right tool. Only when the value must be computed per request — or when the header depends on the response body — do you need a Worker. Reaching for a Worker when a Transform Rule would do adds cold-start latency and a code-maintenance surface for no benefit; reaching for a Transform Rule when you need a body-derived nonce simply cannot work.

Decision tree for choosing a Cloudflare edge header mechanism A single question about the header value source branches to Managed Transforms, Transform Rules, or Workers. Header value source? Fixed, curated nosniff baseline Static or route-based CSP / HSTS strings Per-request value nonce, body hash Managed Transform Transform Rule Worker
Choose the edge mechanism by what the header value depends on: fixed, route-static, or per-request.

The header operation vocabulary is shared. In a Transform Rule each header entry takes an operation of set, add, or remove; in a Worker you call response.headers.set(...), .append(...), or .delete(...). The semantics map one-to-one: set/set, add/append, remove/delete.

Operation Effect on an existing header Security impact When to deviate
set Replaces all copies with one value Guarantees a single authoritative directive Default for every security header
add Appends an additional copy Browser enforces the intersection — unpredictable for CSP/HSTS Only for genuinely multi-valued headers
remove Deletes the header entirely Strips origin leaks (Server, X-Powered-By) Pair with a set if you also inject a replacement

The wire-level consequence of each operation is easiest to see when the origin has already emitted the same header. The figure below traces one origin-supplied Content-Security-Policy through each of the three edge operations and shows what the browser finally receives.

Effect of set, add, and remove on a duplicated header Given one origin copy of a header, set yields one authoritative value, add yields two conflicting copies, and remove strips it entirely. Origin already sent: Content-Security-Policy (1 copy) set add remove wire response CSP: edge value CSP: origin value CSP: edge value (header stripped) 1 authoritative copy correct default 2 copies, intersection unpredictable 0 copies origin leak removed
With an origin copy already present, only set produces a single authoritative header; add leaves two conflicting copies for the browser to intersect.

Malformed-syntax gotchas. A Transform Rule value is a literal string — quoting 'self' inside JSON for the API requires escaping ('\''self'\''), and a forgotten escape silently truncates the CSP. Managed Transforms cannot be partially overridden: if you enable the Add security headers managed transform and also set the same header in a rule, the rule wins, but leaving both on invites confusion during audits. Workers added via the dashboard run in the response phase too, so a Worker that calls .append re-introduces the duplication problem you avoided in your rules.

Platform-Specific Implementation

Cloudflare — Response Header Transform Rules

Configure under Rules → Transform Rules → Modify Response Header, or via the Rulesets API in the http_response_headers_transform phase. An expression of true matches all traffic; scope with wirefilter fields for route-specific policy.

# Expression — apply hardened headers only to HTML documents, not static assets
(http.response.content_type.media_type eq "text/html")
# Header operations (dashboard "Modify Response Header" rows)
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

Every row uses set (not add) — set emits exactly one copy and overwrites any origin value, so a misconfigured backend cannot leak a weaker policy. The remove X-Powered-By row strips an origin information-leak. Full API and Terraform syntax lives in the dedicated Transform Rules reference.

# One-line verification of a Transform Rule deployment
curl -sI https://yourdomain.com | grep -iE 'strict-transport|content-security|x-frame'

Cloudflare — Workers (dynamic per-request headers)

Use a Worker when the header value must be computed per request — the canonical case is a CSP nonce that must appear both in the Content-Security-Policy header and in the HTML <script nonce> attributes. A Transform Rule cannot do this because it cannot read or rewrite the body; a Worker can.

export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request);

    // 128-bit random nonce, base64-encoded, generated per request
    const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));

    // Rewrite the body so inline <script> tags carry the matching nonce
    const rewritten = new HTMLRewriter()
      .on("script", { element(el) { el.setAttribute("nonce", nonce); } })
      .transform(response);

    const headers = new Headers(rewritten.headers);
    // .set() replaces — never .append() for a security header
    headers.set(
      "Content-Security-Policy",
      `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'`
    );
    headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
    headers.set("X-Content-Type-Options", "nosniff");

    return new Response(rewritten.body, {
      status: rewritten.status,
      statusText: rewritten.statusText,
      headers,
    });
  },
};

headers.set() (the Worker equivalent of the rule set operation) replaces any origin copy, so the response carries exactly one Content-Security-Policy. The per-request nonce is the value a Transform Rule cannot produce, which is the deciding factor between the two mechanisms. Worker implementation patterns and route binding are detailed in Cloudflare Workers for security headers.

The ordering matters: the nonce must be generated before both the body rewrite and the header set, because the same value has to appear in the <script nonce> attributes and in the script-src 'nonce-…' directive. If the two diverge — because the nonce is regenerated between the rewrite and the header, or because a cached HTML body carries a stale nonce — every inline script is blocked. The sequence below shows the single-value path through one request.

Per-request CSP nonce sequence through a Cloudflare Worker The Worker fetches the origin HTML, generates one nonce, injects it into script tags and the CSP header, then returns the response. Browser Worker Origin GET /page fetch(request) origin HTML generate 128-bit nonce HTMLRewriter: script nonce headers.set(CSP nonce) response + matching CSP
One nonce is generated per request and reused for both the body rewrite and the CSP header, so header and markup always agree.

Cloudflare — Managed Transforms

For the no-code baseline, enable Rules → Settings → Managed Transforms → Add security headers, which sets X-Content-Type-Options: nosniff and related defaults zone-wide, plus Remove visitor IP headers and sensitive-header stripping. Managed Transforms emit fixed Cloudflare-curated values; the moment you need a custom CSP or HSTS max-age, move that header to a Transform Rule and leave the managed toggle for headers it fully covers.

Verification & Diagnostic Workflows

Edge header bugs are almost always caching or duplication issues, so verification must bypass the cache and count occurrences.

# 1. Inspect injected headers, bypassing the edge cache
curl -sI -H 'Cache-Control: no-cache' https://yourdomain.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
# 2. Confirm NO duplication — must return exactly 1
curl -sI -H 'Cache-Control: no-cache' https://yourdomain.com | grep -ci '^content-security-policy:'

Expected output: 1. Any other number means an add operation or an origin+edge collision.

# 3. Confirm the response was served and rewritten at the edge
curl -sI https://yourdomain.com | grep -iE 'cf-ray|cf-cache-status'

Expected output:

cf-ray: 8a1f2c3d4e5f6789-LHR
cf-cache-status: DYNAMIC

The presence of cf-ray confirms the request reached Cloudflare’s edge (the suffix is the data-center IATA code). A cf-cache-status of DYNAMIC or MISS means the response-header phase ran live; HIT means you may be inspecting a cached copy whose headers were frozen at cache time. In browser DevTools, open the Network tab, disable cache, reload, and inspect the document request’s Response Headers — confirm each security header appears once.

Troubleshooting, Misconfigurations & Safe Rollback

Safe rollback. Transform Rules and Workers are non-destructive at the edge — disabling a rule (PATCH .../rules/{rule_id} with {"enabled": false}) or removing a Worker route instantly reverts to the origin’s own headers. There is no propagation delay. The one irreversible commitment is HSTS preload: once a domain is on the browser preload list, removal takes months to propagate, so validate full HTTPS coverage across every subdomain before adding preload to the value.

Frequently Asked Questions

Should I set security headers at the origin or at the Cloudflare edge?

Set them at one layer and set (overwrite) at the edge if you use both. Edge enforcement is more deterministic because it survives origin framework changes and proxy stripping, but if the origin also emits the header you must use the set operation at the edge to collapse the two into one authoritative value.

Why do my headers appear twice in curl -sI?

Because something is using add/append instead of set. The origin emits one copy and the edge appends a second. Browsers enforce the intersection of duplicate Content-Security-Policy headers, which is unpredictable. Switch every security-header operation to set and re-verify with grep -ci.

Do I need a Worker, or is a Transform Rule enough?

A Transform Rule is enough for any static or route-conditional header value. You need a Worker only when the value must be computed per request — most importantly a CSP nonce that must also be injected into the HTML body, which a Transform Rule cannot read or modify.

Are Page Rules still usable for security headers?

No. Page Rules are deprecated and never supported arbitrary response-header injection. Their only header-adjacent action was Always Use HTTPS. Use Transform Rules for static headers and Workers for dynamic ones.

Why are my new headers not showing up after deployment?

Almost always edge caching. A header baked into a cached object is served unchanged until the cache expires. Purge the cache or use Development Mode, then re-test with curl -sI -H 'Cache-Control: no-cache' and confirm cf-cache-status is MISS or DYNAMIC.