Injecting Security Headers at the Edge

After working through this guide you will be able to attach a complete, consistent security header set — HSTS, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, Permissions-Policy and the Cross-Origin-* isolation trio — in an edge layer, so that every origin sitting behind that layer inherits the same policy without a single backend deploy. You will roll CSP out in report-only mode first, mint a per-request nonce without silently poisoning the cache, and leave the headers your origin already sets alone.

Security headers are the cheapest defensive control you own: a dozen response lines that turn off MIME sniffing, pin transport to TLS, cut off referrer leakage, and stop injected script from executing. The problem in a real estate of services is never the syntax — it is that a Rails app, a static bucket, a Go service and a vendor SaaS subdomain all emit a different subset. Setting the policy once at the edge, on the response path, is how you make the estate uniform. This is response-side work, the mirror image of the request-side edits in modifying request headers at the CDN edge layer.

Key implementation objectives:

  • Own a fixed set of headers at the edge and add them without overwriting values an origin deliberately set.
  • Scope the expensive headers to HTML documents instead of stamping every image, font and script.
  • Ship CSP in Content-Security-Policy-Report-Only first, mine the violation reports, then flip the header name to enforce.
  • Understand why a per-request nonce and a shared cache are mutually exclusive unless you change the cache key or the policy style.
One header policy in front of many origins Three origins with inconsistent headers send responses into a single edge response layer, which adds the full security header set before the browser receives the response. One policy, every origin behind it App origin sets HSTS only Legacy service sets nothing Object storage assets, no HTML Edge response layer Strict-Transport-Security X-Content-Type-Options Referrer-Policy Permissions-Policy Cross-Origin-Opener Cross-Origin-Resource CSP on HTML only origin Browser identical policy on every hostname enriched A backend that forgets a header stops being a gap in the policy

The header set, and what each one actually buys you

Treat this table as the contract the edge enforces. Every value below is a production-safe starting point; tighten Permissions-Policy and CSP once you know your dependency graph.

Header Value to start with What it prevents Apply to
Strict-Transport-Security max-age=31536000; includeSubDomains Protocol downgrade and cookie theft over plaintext All responses
Content-Security-Policy default-src 'self'; script-src 'self' 'nonce-…' Injected and inline script execution HTML only
X-Content-Type-Options nosniff MIME sniffing a text upload into script All responses
Referrer-Policy strict-origin-when-cross-origin Path and query leaking to third parties All responses
Permissions-Policy camera=(), microphone=(), geolocation=() Iframed or injected code grabbing device APIs HTML only
Cross-Origin-Opener-Policy same-origin Cross-window scripting via window.opener HTML only
Cross-Origin-Resource-Policy same-site Your responses being embedded cross-site All responses
Cross-Origin-Embedder-Policy require-corp Un-audited cross-origin subresources HTML, opt-in

Two of these deserve a warning before you touch anything. Cross-Origin-Embedder-Policy: require-corp breaks every cross-origin image, font or iframe that does not itself send Cross-Origin-Resource-Policy — enable it only when you actually need cross-origin isolation for SharedArrayBuffer or precise timers. And Strict-Transport-Security with the preload token is a one-way door: browsers ship the preload list in the binary, removal requests take months to reach users, and every current and future subdomain of the apex is forced onto HTTPS from that moment on.

Prerequisites and environment setup

You need a hostname proxied through your CDN so responses actually transit the edge, plus CLI access to whichever layer will own the headers:

node --version            # v18.x or later
npx wrangler --version    # 3.60.0 or later
npx wrangler whoami       # confirms account + zone access
curl --version | head -n1 # 7.75+ for clean -w formatting

You also need somewhere to receive CSP violation reports. A tiny endpoint that accepts POST with Content-Type: application/csp-report and writes to your log pipeline is enough — a managed report collector works too, but do not skip this step, because report-only mode without a collector tells you nothing.

Finally, take an inventory before you write code. Capture what your origins send today so you can diff afterwards:

for path in / /pricing /api/health /assets/app.css; do
  printf '%s\n' "$path"
  curl -sI "https://www.example.com$path" \
    | grep -iE 'strict-transport|content-security|x-content-type|referrer-policy|permissions-policy|cross-origin'
done

Anything that already appears here is a header an origin owns. Decide deliberately whether the edge should defer to it or override it — that decision is step 1.

Step 1 — Split the header set into “add if absent” and “always override”

The single most common outage from this work is an edge layer that blindly calls set() on every response and flattens a stricter policy an application team shipped last quarter. Classify each header once:

  • Always override: transport and sniffing controls that must not vary — Strict-Transport-Security, X-Content-Type-Options. There is no legitimate reason for one origin to weaken these.
  • Add if absent: policy headers an application may reasonably tighten — Content-Security-Policy, Permissions-Policy, Referrer-Policy, the Cross-Origin-* family. If the origin already sent one, it knows something the edge does not.
  • Strip unconditionally: fingerprinting headers such as Server, X-Powered-By, X-AspNet-Version.

Encoding that split in data rather than in branches keeps the function readable as the list grows, and it makes the policy reviewable in a pull request without reading control flow.

Step 2 — Implement the baseline in a Worker

The Worker fetches the origin response, clones it into a mutable Response, and applies the three classes above. Cloning matters: the Response returned by fetch() has immutable headers on some runtimes, and mutating it in place throws.

const ALWAYS: Record<string, string> = {
  "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
  "X-Content-Type-Options": "nosniff",
};

const IF_ABSENT: Record<string, string> = {
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Cross-Origin-Resource-Policy": "same-site",
};

const HTML_ONLY: Record<string, string> = {
  "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
  "Cross-Origin-Opener-Policy": "same-origin",
};

const STRIP = ["Server", "X-Powered-By", "X-AspNet-Version"];

export default {
  async fetch(request: Request): Promise<Response> {
    const originResponse = await fetch(request);
    const res = new Response(originResponse.body, originResponse);

    for (const [k, v] of Object.entries(ALWAYS)) res.headers.set(k, v);
    for (const [k, v] of Object.entries(IF_ABSENT)) {
      if (!res.headers.has(k)) res.headers.set(k, v);
    }
    for (const h of STRIP) res.headers.delete(h);

    const type = res.headers.get("content-type") ?? "";
    if (type.startsWith("text/html")) {
      for (const [k, v] of Object.entries(HTML_ONLY)) {
        if (!res.headers.has(k)) res.headers.set(k, v);
      }
    }
    return res;
  },
};

Expected side effect after npx wrangler deploy: every response gains the two mandatory headers, HTML documents gain the isolation pair, and Server disappears from the wire. Nothing else about the response changes — no body is buffered, so this costs well under a millisecond of CPU.

Step 3 — Keep CSP off your assets

Sending Content-Security-Policy on a PNG is harmless but wasteful: a serious policy is several hundred bytes, and multiplied across sixty subresources on a page it is real bandwidth on every single response, none of which a browser ever evaluates. Branch on content-type as shown above, and remember that the check must run after the origin fetch, because only the origin knows the type.

Two failure modes to watch. First, an origin that omits Content-Type entirely leaves your startsWith check false, so the document silently loses its policy — log those cases rather than defaulting them to HTML. Second, content-type values carry parameters (text/html; charset=utf-8), so always use startsWith and never an equality test.

Step 4 — Roll CSP out in report-only mode

A policy written from intuition will break something. The whole point of Content-Security-Policy-Report-Only is that the browser evaluates the policy, reports every violation, and enforces nothing. Ship it, watch it for a full business cycle — a month catches the marketing tag someone only fires during campaigns — then tighten and enforce.

CSP rollout stages Four sequential stages: publish report-only, collect violation reports, tighten the policy from the report data, then rename the header to enforce it. Rollout order, not policy quality, decides the outcome 1. Report-Only nothing blocked 2. Collect 2 to 4 weeks 3. Tighten drop wildcards 4. Enforce rename header report-to endpoint group by blocked-uri, then by document-uri a flat line means the policy is complete violations allowlist Enforcing before report volume flattens turns a policy bug into an outage Rollback is renaming the header back — no redeploy of any origin

The report-only header is identical in syntax to the enforcing one, plus a reporting destination:

const REPORT_ONLY = [
  "default-src 'self'",
  "script-src 'self' https://cdn.example.com",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https:",
  "frame-ancestors 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  "report-uri /_csp/report",
].join("; ");

res.headers.set("Content-Security-Policy-Report-Only", REPORT_ONLY);

Read the reports by blocked-uri first: a scattering of one-off hosts is usually browser extensions and can be ignored, while a single host appearing on thousands of documents is a real dependency you forgot. frame-ancestors 'none' and base-uri 'self' are the two directives worth enforcing early — almost nothing legitimate breaks, and they close clickjacking and base-tag injection.

Step 5 — Nonces, and why they fight the cache

'unsafe-inline' in script-src makes the policy decorative. The two ways out are hashes (enumerate every inline script’s SHA-256) and nonces (a fresh random token per response, echoed on each <script> tag). Nonces are far easier to operate — until you remember that the document carrying the nonce is a cacheable object.

Nonce versus shared cache An HTML response carrying a nonce splits into two outcomes: stored in a shared cache the nonce is replayed and scripts break, or the response bypasses the cache and stays correct. HTML + nonce-a1b2c3 header and script tag agree Stored in a shared cache every visitor replays nonce-a1b2c3 the nonce stops being a secret Kept out of shared storage no-store on the document, or hash-based CSP with a long TTL A per-request value inside a cacheable body is a bug decide caching before you decide nonce or hash

Generating the nonce and stamping it onto the document is a few lines with HTMLRewriter, which streams the body rather than buffering it:

function newNonce(): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  return btoa(String.fromCharCode(...bytes)).replace(/=+$/, "");
}

export default {
  async fetch(request: Request): Promise<Response> {
    const upstream = await fetch(request);
    const type = upstream.headers.get("content-type") ?? "";
    if (!type.startsWith("text/html")) return upstream;

    const nonce = newNonce();
    const res = new Response(upstream.body, upstream);
    res.headers.set(
      "Content-Security-Policy",
      `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`,
    );
    // The document must not be reused for another visitor.
    res.headers.set("Cache-Control", "private, no-store");

    return new HTMLRewriter()
      .on("script", {
        element(el) {
          if (!el.getAttribute("src") || el.getAttribute("data-csp") === "nonce") {
            el.setAttribute("nonce", nonce);
          }
        },
      })
      .transform(res);
  },
};

Note the deliberate private, no-store on the document. If you need shared caching of HTML, you have three options and no others: put the nonce in the cache key (which gives every visitor a unique key and a permanent hit ratio of zero — pointless), move the nonce injection into the origin render that the cache already varies on, or drop nonces and switch to hashes, which are stable across requests and therefore cacheable. Hash-based policies pair well with fingerprinted assets, as covered in customizing cache keys to improve hit ratio.

Step 6 — The declarative alternative: CDN header rules

If the only requirement is a static header set, you do not need compute at all. Cloudflare’s response header transform ruleset runs in the proxy hot path, costs no CPU budget, applies on cache hits as well as misses, and reverts with a single API call. It cannot mint a nonce — that always needs code, which is the same declarative-versus-programmatic boundary drawn across Request/Response Transformation generally.

{
  "rules": [
    {
      "description": "Baseline security headers on every response",
      "expression": "true",
      "action": "rewrite",
      "action_parameters": {
        "headers": {
          "Strict-Transport-Security": { "operation": "set", "value": "max-age=31536000; includeSubDomains" },
          "X-Content-Type-Options": { "operation": "set", "value": "nosniff" },
          "Referrer-Policy": { "operation": "set", "value": "strict-origin-when-cross-origin" },
          "Server": { "operation": "remove" },
          "X-Powered-By": { "operation": "remove" }
        }
      }
    },
    {
      "description": "Document-only policy headers",
      "expression": "http.response.content_type.media_type == \"text/html\"",
      "action": "rewrite",
      "action_parameters": {
        "headers": {
          "Permissions-Policy": { "operation": "set", "value": "camera=(), microphone=(), geolocation=()" },
          "Cross-Origin-Opener-Policy": { "operation": "set", "value": "same-origin" }
        }
      }
    }
  ]
}
curl -X PUT \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/rulesets/phases/http_response_headers_transform/entrypoint" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d @security-headers.json

The response includes a version field. Record it — rollback is re-PUTting the previous payload, and it propagates globally in seconds. Rules like these evaluate alongside your firewall policy, so pair them with the request-side controls in blocking common attacks with Cloudflare WAF rules rather than treating either as a substitute for the other.

Step 7 — HSTS, includeSubDomains, and the preload trapdoor

Raise max-age in stages: 300 seconds, then a day, then a week, then a year. At each step confirm nothing on the hostname still depends on plaintext HTTP. Only then consider includeSubDomains, and enumerate your zone first — an internal jenkins. or legacy-vpn. host with a self-signed certificate becomes unreachable in every modern browser the moment the directive lands, with no click-through available.

preload goes further: it hard-codes the domain into browser binaries. Removal is a submission, a review, and then months of waiting for users to update. Add it only when the apex and every subdomain have been fully HTTPS for a long time and you are certain no future subdomain will need otherwise.

Verification

Confirm the document carries the full set, that assets carry only the cheap subset, and that the nonce is fresh per request.

curl -sI https://www.example.com/ | grep -iE \
  'strict-transport|content-security|x-content-type|referrer-policy|permissions-policy|cross-origin|^server'
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: default-src 'self'; script-src 'self' 'nonce-9f2c1a...'
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-site

An asset should show the always-on headers and no CSP:

curl -sI https://www.example.com/assets/app.css | grep -icE 'content-security-policy'
# 0

Two requests must yield two different nonces. If they match, the document is being served from a cache:

for i in 1 2; do
  curl -sI https://www.example.com/ | grep -io "nonce-[a-z0-9+/]*"
done

Finally watch the report endpoint receive traffic while you deliberately trip the policy, and tail the edge logs to see which branch ran:

npx wrangler tail --format pretty --status error

Troubleshooting

A third-party script stops loading after enforcement

Open the browser console and read the violation: it names the directive and the blocked-uri. The usual cause is a tag manager that injects further scripts from hosts you never listed. Rather than adding twenty hostnames, add 'strict-dynamic' to script-src — a nonced loader may then load its own dependencies without each host being enumerated. Diagnose with curl -sI <url> | grep -i content-security-policy to confirm which policy the browser actually received, since a stale cached document will show the old one.

Duplicate headers with conflicting values

If both the edge and the origin set Referrer-Policy, the wire carries two lines and browser behavior across implementations is not something you want to rely on. For CSP the semantics are worse but well defined: multiple policies are all enforced and the effective result is their intersection, so a second permissive policy cannot loosen the first — but a second strict one silently breaks the page. Check with curl -sD - -o /dev/null <url> | grep -ci content-security-policy; a count above 1 means your “add if absent” logic ran append instead of set, or a declarative rule and a Worker are both writing. Pick exactly one owner per header.

The nonce is cached and reused

Symptom: scripts execute for a minute after deploy and then break for everyone at a specific PoP. The document was stored by a shared cache with its nonce baked into the body while the header was regenerated — or vice versa. Confirm with the two-request loop above and by reading cf-cache-status; anything other than DYNAMIC or BYPASS on a nonced document is the bug. Fix by setting Cache-Control: private, no-store on HTML that carries a nonce, or move to hash-based policy so the document becomes cacheable again.

HSTS locks a staging subdomain out

Symptom: staging.example.com returns a certificate error with no proceed option, immediately after includeSubDomains shipped on the apex. The browser has cached the policy for the whole tree and will not accept the staging certificate for max-age seconds. There is no server-side fix — the policy lives in the client. Recovery is: issue a publicly trusted certificate for the staging host, or clear the HSTS entry per browser (chrome://net-internals/#hsts accepts a delete for a domain), and use a separate apex such as a dedicated staging domain for anything that cannot hold a real certificate. This is exactly why you raise max-age in stages before enabling includeSubDomains.

Frequently Asked Questions

Should the edge or the origin own security headers? The edge should own the headers that must be uniform — transport security and MIME sniffing — and defer to the origin for policy headers an application may legitimately tighten. A single owner per header is the rule that matters; two layers writing the same header is how duplicates and surprise regressions happen.

Can I use a CSP nonce and still cache HTML at the edge? Not in a shared cache. A nonce is valid for exactly one response, so a cached document replays a stale token and every nonced script is blocked. Either mark nonced documents private, no-store, or switch to hash-based script-src values, which are stable across requests and therefore cacheable.

Do these headers need to be on images, CSS and JavaScript too? Only some of them. X-Content-Type-Options, Strict-Transport-Security and Cross-Origin-Resource-Policy belong on every response. Content-Security-Policy, Permissions-Policy and Cross-Origin-Opener-Policy are evaluated per document, so sending them with each subresource costs bandwidth and buys nothing.

What actually happens if I set preload on HSTS and then regret it? Removal requires a submission to the preload list, a review, a browser release, and then the natural update cycle of every installed browser — realistically many months during which the domain and all its subdomains stay HTTPS-only. Treat preload as permanent and add it only after a long, clean run at a one-year max-age.

Is a declarative CDN rule enough, or do I need edge compute? A rule engine covers the entire static set, applies on cache hits, and rolls back in one API call, so use it whenever the values do not change per request. Reach for a Worker only when you need a nonce, a policy that varies by route or user, or logic that inspects the origin response before deciding.

Back to Request/Response Transformation