Setting Content-Security-Policy in Apache .htaccess
This guide is part of the Apache .htaccess & VirtualHost Hardening reference. A Content-Security-Policy constrains which scripts, styles, frames, and connections a page may load, and Apache emits it with one mod_headers directive. The directive is short; the failure modes are not. A policy that works in a <VirtualHost> silently does nothing in .htaccess when AllowOverride forbids it, a long policy broken across lines parses as garbage, and Header set without always leaves your error pages — the ones an attacker probes — unprotected.
Configuration Syntax & Exact Values
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
Annotated breakdown:
Header— provided bymod_headers. The module must be loaded (a2enmod headerson Debian/Ubuntu, orLoadModule headers_module modules/mod_headers.so). Without it Apache fails to start withInvalid command 'Header'.always— selects thealwaysresponse table, so the header attaches to error responses (4xx/5xx) as well as2xx/3xx. Omitting it defaults toonsuccess, which leaves custom error pages with no CSP. Usealwaysfor security headers.set— replaces any existing value for the header. Prefersetoveraddfor CSP;addcan emit a secondContent-Security-Policyline, and browsers intersect multiple policies, producing a combined policy stricter than either one intended.- The quoted value — the entire policy must be wrapped in one pair of double quotes. The directives are separated by semicolons inside the quotes; the semicolon has no special meaning to Apache here, only to the browser parsing the CSP.
The whole policy must sit on a single logical line. Apache’s \ line continuation is brittle inside a quoted value — a trailing space after the backslash, or a continuation inside the quotes, breaks the parse and Apache either errors on start or emits a truncated header. Keep the directive on one physical line however long it is:
# CORRECT — one physical line, however long
Header always set Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
# WRONG — backslash continuation inside the value; truncates or fails to parse
Header always set Content-Security-Policy "default-src 'self'; \
script-src 'self'"
Server-Side Configuration
.htaccess
Drop the directive into the .htaccess file at the document root (or a subdirectory to scope it). It takes effect on the next request — no reload — which is the file’s main advantage and the source of its performance cost (covered below).
# /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>
The <IfModule> guard keeps the directory usable if mod_headers is ever unloaded, instead of returning a 500 on every request.
VirtualHost equivalent
The identical directive belongs in the <VirtualHost> when you control the server config. This is faster (parsed once at startup, not per request) and cannot be disabled by an AllowOverride setting. Prefer it whenever you have access; reserve .htaccess for shared hosting where you do not.
<VirtualHost *:443>
ServerName app.yourdomain.com
DocumentRoot /var/www/html
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
</VirtualHost>
Report-Only first
Deploy with Content-Security-Policy-Report-Only before enforcing. The browser evaluates the policy and reports violations but blocks nothing, so you discover what a strict policy would break without taking the site down. Run Report-Only until the violation stream is clean, then switch the header name to Content-Security-Policy.
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-uri /csp-reports"
Conditional per-path with /
Scope a stricter or looser policy to a path. <Location> matches the URL; <If> matches an expression. Both override the broader policy for the matched requests because set replaces.
# A relaxed policy for an embeddable widget path
<Location "/embed/">
Header always set Content-Security-Policy "default-src 'self'; frame-ancestors *"
</Location>
# Drop CSP on a specific health-check endpoint
<If "%{REQUEST_URI} =~ m#^/healthz$#">
Header always unset Content-Security-Policy
</If>
Diagnostic & Verification Steps
Confirm the config parses before reloading — this catches the line-continuation and quoting mistakes:
sudo apachectl -t
Syntax OK
A broken continuation reports the offending file and line instead of Syntax OK.
Check the emitted header with curl:
curl -sI https://app.yourdomain.com/ | grep -i content-security-policy
content-security-policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
Confirm it appears on error responses too (this is what always buys you):
curl -sI https://app.yourdomain.com/does-not-exist | grep -i content-security-policy
content-security-policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
If the header is present on the 200 but missing on the 404, the directive is missing always — it is running under onsuccess.
Confirm exactly one policy line (a stray add produces two):
curl -sI https://app.yourdomain.com/ | grep -ci 'content-security-policy'
The count must be 1.
Edge Cases, Security Implications & Safe Rollback
AllowOverridemust permit it. A.htaccessHeaderdirective only takes effect if the governing<Directory>block allows it.mod_headersdirectives requireAllowOverride FileInfo(orAll). WithAllowOverride Nonethe file is ignored entirely — no error, no header. Verify withapachectl -t -D DUMP_INCLUDESor test the emitted header directly with curl..htaccessperformance. Apache re-reads.htaccesson every request, walking the full directory chain to the document root. On a busy site this is measurable. Move the directive into the<VirtualHost>and setAllowOverride Nonein production to skip the lookup entirely.- Nonces cannot come from
.htaccess. A nonce must be unique per response and matched to a value rendered into the page’s<script nonce="...">..htaccessis static — it cannot generate a per-request value — soscript-src 'nonce-...'belongs in the application, not the header file. Use the app layer to mint and inject the nonce;.htaccessis for static policies only. - Escaping quotes and semicolons. The policy value is one double-quoted string. A literal double quote inside it must be backslash-escaped (
\"), though a well-formed CSP rarely needs one. Semicolons inside the quotes are fine — they are CSP directive separators, transparent to Apache. The mistake is closing the quote early (an unescaped"mid-policy), which truncates the header at the quote and silently ships a partial, weaker policy. Verify the full value with the curl check above. - Safe rollback. CSP changes are non-destructive at the server level — reverting only changes which header attaches. To roll back, restore the previous directive (or switch enforce back to
Content-Security-Policy-Report-Only), runapachectl -t, thenapachectl gracefulfor a<VirtualHost>change. For.htaccess, the edit applies on the next request with no reload. Re-run the curl checks to confirm.
Header always set directive from .htaccess and attaches the CSP via the always table, so it reaches the browser on both success and error responses; onsuccess would cover only 2xx/3xx.Frequently Asked Questions
Why is my CSP header missing even though .htaccess has the directive?
Two common causes. Either mod_headers is not loaded (apachectl -M | grep headers shows nothing), or the governing <Directory> uses AllowOverride None, which makes Apache ignore the entire .htaccess. Header directives need AllowOverride FileInfo or All.
Should I use Header set or Header add for CSP?
Use set. set replaces, guaranteeing one policy line. add appends, and a second Content-Security-Policy line makes the browser enforce the intersection of both policies — usually stricter than intended and hard to debug.
Can I generate a CSP nonce in .htaccess?
No. A nonce must be unique per response and matched to the page markup, and .htaccess only emits static values. Generate the nonce in the application and inject it into both the header and the <script nonce> attribute there.
Why does apachectl -t say Syntax OK but the header is truncated?
A double quote closed early inside the policy value ends the string there; everything after is a separate token Apache may ignore. The syntax can still pass while the emitted header is a partial policy. Verify the full value with curl -sI ... | grep -i content-security-policy, not just the syntax check.
Conclusion
Roll this out incrementally. In staging, deploy the policy as Content-Security-Policy-Report-Only, validate with apachectl -t and curl -sI, and watch the violation reports until they are clean. Switch the header name to Content-Security-Policy with always, confirm it appears on both 200 and 404 responses, then promote the directive from .htaccess into the <VirtualHost> for production and set AllowOverride None to drop the per-request lookup.
Related
- Apache .htaccess & VirtualHost Hardening — precedence, scoping, and AllowOverride rules this builds on.
- Apache mod_headers Best Practices for Security — when to use set, append, edit, unset, and the always/onsuccess selector.
- Content Security Policy essentials — the directive reference behind the policy values above.