Using Cloudflare Workers to Set Security Headers

This guide is part of the Cloudflare security headers reference and shows how to set security headers from a Cloudflare Worker that fetches the origin, rewrites the response headers, and — where the value must also appear in the HTML — injects a per-request Content-Security-Policy nonce with HTMLRewriter. A Worker runs in the response path as code, so unlike the static-only Transform Rules approach it can compute a fresh value per request and mutate both the headers and the body together.

Configuration Syntax & Exact Values

A header-setting Worker has two responsibilities: fetch the origin response, then return a copy with the security headers overwritten. The headers it sets are the same authoritative strings you would inject anywhere else:

Strict-Transport-Security    max-age=63072000; includeSubDomains; preload
Content-Security-Policy      default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'
X-Frame-Options              DENY
X-Content-Type-Options       nosniff
Referrer-Policy              strict-origin-when-cross-origin
Permissions-Policy           geolocation=(), camera=(), microphone=()

The Worker overwrites rather than appends. Headers.set(name, value) replaces every existing copy of the header — origin or earlier — so exactly one authoritative directive reaches the client. Headers.append(name, value) would leave the origin copy in place, and browsers enforce the intersection of duplicate Content-Security-Policy lines, producing unpredictable policy. Use set for every security header.

When to use a Worker instead of Transform Rules. Transform Rules run in the http_response_headers_transform phase and cannot read or rewrite the response body. They are the right tool for static header strings. Reach for a Worker only when the value is dynamic per request — most importantly a CSP nonce, which must appear both in the Content-Security-Policy header and in every inline <script nonce="..."> attribute in the HTML. Synchronising those two places requires reading and editing the body, which only a Worker (via HTMLRewriter) can do. For everything static, prefer Transform Rules: they are cheaper and have no CPU budget.

Worker rewrites headers and injects a CSP nonce A client request reaches the Worker, which fetches the origin, rewrites response headers and injects a nonce via HTMLRewriter, then returns the response to the client. Client request Worker fetch origin rewrite headers HTMLRewriter inject nonce Origin response Client secured nonce written to header and inline script in one pass
The Worker fetches the origin response, overwrites the security headers, injects a per-request nonce into both the CSP header and the HTML, then returns the secured response.

Server-Side Configuration

Complete Worker fetch handler setting all security headers

This module-syntax Worker fetches the origin and returns a copy with every security header overwritten. The headers are set on a cloned response so the original immutable headers from fetch are not mutated in place.

const SECURITY_HEADERS = {
  "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
  "X-Frame-Options": "DENY",
  "X-Content-Type-Options": "nosniff",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Permissions-Policy": "geolocation=(), camera=(), microphone=()",
};

export default {
  async fetch(request) {
    const originResponse = await fetch(request);

    // Clone into a mutable response; fetch() headers are immutable.
    const response = new Response(originResponse.body, originResponse);

    for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
      response.headers.set(name, value); // set overwrites; never append
    }

    // Static CSP when no nonce is needed (see next section for the nonce path).
    response.headers.set(
      "Content-Security-Policy",
      "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'"
    );

    return response;
  },
};

response.headers.set(...) replaces any origin copy of the header, guaranteeing one authoritative value on the wire. addEventListener("fetch", ...) with event.respondWith(...) is the older service-worker syntax and behaves identically; the module export default { fetch } form above is the current standard.

HTMLRewriter to inject a CSP nonce into <script> and the header

When the CSP uses a nonce, the same random value must land in the Content-Security-Policy header and on every inline <script> tag. Generate one nonce per request with the Web Crypto API, set it in the header, and use HTMLRewriter to stamp the nonce attribute onto inline scripts as the body streams through.

export default {
  async fetch(request) {
    // 16 random bytes, base64-encoded — fresh per request.
    const bytes = crypto.getRandomValues(new Uint8Array(16));
    const nonce = btoa(String.fromCharCode(...bytes));

    const originResponse = await fetch(request);
    const response = new Response(originResponse.body, originResponse);

    response.headers.set(
      "Content-Security-Policy",
      `default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`
    );
    response.headers.set("X-Content-Type-Options", "nosniff");
    response.headers.set("X-Frame-Options", "DENY");

    // Only rewrite HTML; leave assets and JSON untouched.
    const contentType = response.headers.get("content-type") || "";
    if (!contentType.includes("text/html")) {
      return response;
    }

    return new HTMLRewriter()
      .on("script", {
        element(el) {
          // Stamp the nonce only on inline scripts (no src attribute).
          if (!el.hasAttribute("src")) {
            el.setAttribute("nonce", nonce);
          }
        },
      })
      .transform(response);
  },
};

The header value and the attribute value are the same nonce variable, so they cannot drift. 'strict-dynamic' lets a nonced script load further scripts it trusts without each one needing its own nonce; drop it if you want to enumerate every source explicitly. The text/html guard avoids running the rewriter on images, fonts, and API responses, which wastes CPU and can corrupt non-HTML bodies.

wrangler deploy

Bind the Worker to the hostname with a route, then deploy. The route is what activates the Worker in the response path; removing it is also how you roll back (see Edge Cases).

# wrangler.toml
name = "security-headers"
main = "src/index.js"
compatibility_date = "2026-01-01"

[[routes]]
pattern = "example.com/*"
zone_name = "example.com"
wrangler deploy

Diagnostic & Verification Steps

# Inspect the headers the Worker emits, bypassing the edge cache
curl -sI -H 'Cache-Control: no-cache' https://example.com \
  | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|cf-worker'

Expected output:

strict-transport-security: max-age=63072000; includeSubDomains; preload
content-security-policy: default-src 'self'; script-src 'nonce-Yk3p...'; 'strict-dynamic'; object-src 'none'; base-uri 'none'
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
cf-worker: example.com

The cf-worker header confirms a Worker handled the request. To prove the nonce is fresh per request, fetch twice and compare:

# The nonce must differ between two requests
for i in 1 2; do
  curl -sI -H 'Cache-Control: no-cache' https://example.com \
    | grep -io "nonce-[A-Za-z0-9+/=]*"
done

Expected output (two different values):

nonce-Yk3pQ2vF8sLm1aWtZ0bQdg==
nonce-Rt9xN4cJ7uPe2bXsY1aKfw==
# Confirm exactly one CSP header — origin + Worker duplication returns 2
curl -sI -H 'Cache-Control: no-cache' https://example.com | grep -ci '^content-security-policy:'

Expected output: 1.

In browser DevTools, open the Network tab, disable cache, reload, and confirm the document request shows one content-security-policy header whose nonce- value matches the nonce attribute on each inline <script> in the Elements panel. A mismatch means the rewriter and header used different values.

Edge Cases, Security Implications & Safe Rollback

Safe rollback. A Worker is deactivated by removing its route — the response path reverts to origin headers immediately, with no propagation delay.

  1. Delete the route in Workers & Pages → your Worker → Triggers → Routes, or remove the [[routes]] block and run wrangler deploy.
  2. Alternatively delete the Worker entirely: wrangler delete.
  3. Verify origin fallback headers with the cache-bypass curl above; cf-worker should disappear.

Frequently Asked Questions

When should I use a Worker instead of Transform Rules for headers?

Use a Worker only when the header value is dynamic per request — chiefly a CSP nonce that must also appear in the HTML body. Transform Rules cannot read or rewrite the body, so they cannot synchronise a nonce. For static header strings, prefer Transform Rules because they have no CPU budget and are cheaper.

How do I inject a CSP nonce into inline scripts with a Worker?

Generate one nonce per request with crypto.getRandomValues, set it in the Content-Security-Policy header, and use HTMLRewriter().on("script", ...) to add the same nonce attribute to inline <script> tags as the body streams. Because both reference the same variable, the header and the markup cannot drift.

Why must I send no-store on a nonced page?

A nonce is single-use per request. If the edge or browser caches the HTML, every visitor receives the same stale nonce that no longer matches a freshly minted header value, breaking the policy. Set Cache-Control: no-store on nonced HTML so each request gets its own nonce.

Will a Worker error strip my security headers?

It can. An uncaught exception may return an error page or fall through to the origin without the headers. Wrap the handler in try/catch and choose explicitly between failing closed (re-throw) or returning the origin response, so missing headers are never a silent accident.

Conclusion

Deploy the Worker against a staging hostname or a report-only CSP first, confirm with the cache-bypass curl and the two-request nonce-diff check, then bind it to the production route. Start HSTS at a short max-age, verify full HTTPS coverage across every subdomain, and only then raise it and add preload.