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.
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
- CPU cost. A Worker is billed and bounded by CPU time per request. Streaming
HTMLRewriteris cheap because it does not buffer the whole body, but a Worker that readsawait response.text()on a large page can hit the CPU limit and fail. Stream withHTMLRewriterand skip non-HTML responses with thecontent-typeguard. - Caching interaction. A nonce must never be cached. If a nonced HTML page is stored in the edge cache, every visitor receives the same stale nonce and the header no longer matches the per-request body. Send
Cache-Control: no-store(orprivate, no-cache) on nonced HTML, or bypass cache for the Worker route. Static, non-nonced headers cache safely; purge the cache after deploy withPOST /zones/{zone_id}/purge_cache. - Double headers with the origin. If the origin already emits a security header and the Worker uses
appendinstead ofset, the client receives two copies and enforces their intersection. Alwaysset; verify with thegrep -cicommand returning1. - Failure mode. An uncaught exception in the Worker can return an error page or, depending on configuration, fall through to the origin without the headers — a fail-open. Wrap the logic in
try/catchand decide explicitly: re-throw to fail closed, or return the bare origin response and accept missing headers. Never let a thrown error silently strip security headers in production. - HSTS
preloadlock-in. Once submitted to the browser preload list,preloadremoval takes months. Confirm full HTTPS coverage across every subdomain before shipping it.
Safe rollback. A Worker is deactivated by removing its route — the response path reverts to origin headers immediately, with no propagation delay.
- Delete the route in Workers & Pages → your Worker → Triggers → Routes, or remove the
[[routes]]block and runwrangler deploy. - Alternatively delete the Worker entirely:
wrangler delete. - Verify origin fallback headers with the cache-bypass
curlabove;cf-workershould 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.