Apache Header set vs append vs add for CSP
This guide is part of the Apache .htaccess & VirtualHost Hardening reference. Apache’s mod_headers gives you four verbs that all write response headers — set, append, add, and edit — and they are not interchangeable. Choose the wrong one for a Content-Security-Policy and you either replace a policy you meant to extend, comma-join two policies into a value browsers reject, or emit two separate Content-Security-Policy lines that browsers intersect into something stricter than anything you wrote. This page fixes the exact semantics of each verb, shows the duplicate-header bug add produces, and explains why always and directive ordering decide whether the header ever reaches an error response.
Configuration Syntax & Exact Values
Every mod_headers directive follows the same shape. The verb is the second token, and it selects one of four distinct behaviours against the existing value of the named header:
Header always set Content-Security-Policy "default-src 'self'"
Header always append Content-Security-Policy "default-src 'self'"
Header always add Content-Security-Policy "default-src 'self'"
Header always edit Content-Security-Policy "'unsafe-inline'" ""
Annotated breakdown of the four verbs:
set— unconditionally replaces the header. IfContent-Security-Policyalready exists in the response, its value is discarded and yours becomes the sole value. Exactly one header line results. This is the correct verb for CSP in almost every case.append— if the header already exists, Apache joins your value to the existing one with a comma and space (,), producing a single header line. If the header does not exist,appendcreates it with your value alone. For CSP the comma is destructive: a comma inside a CSP value starts a second policy, and the browser enforces the intersection.add— always creates a new, separate header line even when one already exists. The response then carries twoContent-Security-Policyheaders. Browsers treat multiple CSP headers as multiple policies and enforce all of them simultaneously (the intersection).addis the classic source of accidental double-CSP.edit— takes a header name, a regex, and a replacement, and rewrites the existing value in place. It matches against the current value and substitutes; it never creates a header that is absent. Useeditto surgically remove or rewrite one token (for example strip'unsafe-inline'), not to author a policy.
The value string is a single double-quoted token to Apache. Semicolons inside it are meaningful only to the browser’s CSP parser; commas, however, are meaningful to both — Apache passes them through untouched, and the browser splits on them.
Server-Side Configuration
VirtualHost — the canonical single-policy pattern
Author the policy once with set in the <VirtualHost>. This parses at startup, cannot be disabled by AllowOverride, and guarantees exactly one header. This is the pattern for Apache you want in production.
<VirtualHost *:443>
ServerName example.com
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
</IfModule>
</VirtualHost>
The always keyword selects the always response table so the header attaches to 4xx/5xx error responses, not only 2xx/3xx. Security headers must use always — an attacker probing /admin and getting a 403 should still receive your CSP.
.htaccess — same verb, request-time cost
The identical set directive works in .htaccess where you lack server-config access. It applies on the next request with no reload, but is re-parsed per request and only takes effect where AllowOverride permits mod_headers (typically AllowOverride All or a Header-specific override).
# /var/www/html/.htaccess
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
</IfModule>
Extending a policy set upstream — append vs a second set
If a parent scope (server config, a <Directory> block, or a proxied backend) already sends CSP and you must add to it, do not reach for append — the comma-join breaks CSP. Instead re-author the full policy with set in the narrower scope, which replaces the inherited value cleanly:
# Narrow scope replaces the inherited policy — no comma-join, one line.
<Directory "/var/www/html/reports">
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'self'"
</IfModule>
</Directory>
edit — removing one token without rewriting the whole policy
edit (or edit* for all matches) rewrites the existing value with a regex. Use it to strip a single directive during a migration, for example dropping 'unsafe-inline' from script-src once nonces are in place:
<IfModule mod_headers.c>
# Remove the literal token "'unsafe-inline'" wherever it appears in the value.
Header always edit* Content-Security-Policy "\s*'unsafe-inline'" ""
</IfModule>
edit never creates the header — if no Content-Security-Policy is present, this directive is a no-op. Pair it with a set earlier in the chain that establishes the base policy.
Diagnostic & Verification Steps
Count the header lines. The single most useful check is whether the response carries one Content-Security-Policy header or two — curl -sI prints every line as the server sent it:
curl -sI https://example.com/ | grep -i content-security-policy
Correct output — exactly one line:
content-security-policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
Broken output from add — two separate lines, both enforced as the intersection:
content-security-policy: default-src 'self'
content-security-policy: script-src 'self'
Broken output from append against an existing policy — one line, comma-joined, parsed as two policies by the browser:
content-security-policy: default-src 'self', script-src 'self'
Verify the always behaviour by forcing an error response and confirming the header still appears. Request a path that returns a 404 and check the header is present:
curl -sI https://example.com/this-path-does-not-exist | grep -i content-security-policy
If that returns nothing but the homepage returns the header, the directive is missing always (it is running under the onsuccess table). In Chrome or Firefox DevTools, open the Network panel, select the document request, and read the Response Headers pane — a duplicate CSP shows as two content-security-policy entries, and the Console logs a CSP violation for anything the intersection forbids even though your intended policy allowed it.
Edge Cases, Security Implications & Safe Rollback
Trap 1 — the browser enforces the intersection, silently tightening. When add or append produces two policies, the browser does not pick one and does not union them. It enforces every policy, and a resource must be allowed by all of them to load. A page that works under default-src 'self' alone will suddenly block its own inline handler once a second, stricter policy joins it — and the failure surfaces as a runtime CSP violation, not an Apache error, so it never appears in the config test. Always confirm the header count, not just that “a CSP is present.”
Trap 2 — set in a child scope does not merge, it replaces. mod_headers directives are evaluated per scope, and set in a <Directory> or .htaccess throws away the value inherited from the <VirtualHost>. If your VirtualHost policy carried frame-ancestors 'self' and a subdirectory’s set omits it, that subdirectory ships without frame protection. When you scope a policy narrower, re-state the full policy — never assume inheritance merges values, because it does not for set.
Trap 3 — ordering and the always table are independent axes. Within one scope, directives run top to bottom, so a later set overrides an earlier one; but Header set (the onsuccess table) and Header always set (the always table) are separate lists that do not override each other. Defining CSP with always set and, elsewhere, set for the same header leaves you with divergent policies on success versus error responses. Standardise on always for every security header and grep your config to prove no bare Header set Content-Security-Policy survives.
Safe rollback: because mod_headers writes are declarative, rollback is removing or commenting the directive.
# Roll back by neutralising the verb, not by leaving a stray add/append behind.
<IfModule mod_headers.c>
# Header always set Content-Security-Policy "default-src 'self'"
Header always unset Content-Security-Policy
</IfModule>
Header always unset Content-Security-Policy strips every value of the header from the always table in that scope, guaranteeing a clean slate before you re-apply. In a <VirtualHost>, run apachectl configtest then apachectl graceful to reload without dropping connections; in .htaccess, the change is live on the next request, so keep a backup of the file to restore instantly.
Conclusion
Prove the verb in staging first: apply Header always set and confirm curl -sI shows exactly one Content-Security-Policy line on both a 200 and a 404. Roll it to production behind Content-Security-Policy-Report-Only so a mistaken policy reports instead of blocks, watch the report stream, then flip the header name to the enforcing Content-Security-Policy. Keep set as the only verb that authors the policy, reserve edit for surgical token removal, and never let add or append touch a CSP header.
Frequently Asked Questions
Why does Header add produce two Content-Security-Policy headers?
add is defined to always emit a new header line regardless of whether one already exists. When a parent scope, an earlier directive, or the backend already set CSP, add appends a second line rather than replacing the first. Browsers then enforce both policies as an intersection, so use set unless you deliberately want multiple headers.
Is Header append safe for Content-Security-Policy?
No. append joins your value to the existing one with a comma, and a comma inside a CSP value delimits a second policy. The browser splits on it and enforces both halves as the intersection, which almost never matches your intent. Re-author the complete policy with set in the narrower scope instead.
What does Header edit do that set cannot?
edit rewrites the existing value with a regex substitution, letting you remove or change one token — for example stripping 'unsafe-inline' — without retyping the whole policy. It never creates the header if it is absent, so it only works downstream of a directive that already established CSP. Use edit* to replace every match rather than just the first.
Why is always required on a CSP directive?
Without always, the directive runs under the onsuccess table and only attaches the header to 2xx and 3xx responses. Error pages such as 403 and 404 — exactly the responses an attacker probes — ship with no CSP. Header always set attaches the header across all response codes.
How do I completely remove a CSP before re-applying it?
Run Header always unset Content-Security-Policy in the relevant scope. It strips every value of that header from the always table, clearing duplicates left by an earlier add. Follow with a single Header always set to establish one clean, authoritative policy.