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 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

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.

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.