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:

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

CSP set in .htaccess flowing to the response A request reaches Apache, mod_headers reads the .htaccess Header directive, and the always table attaches CSP to both success and error responses sent to the browser. Browser request Apache + mod_headers reads .htaccess Header always set CSP always: 2xx + 4xx/5xx onsuccess: 2xx only Response Content-Security- Policy header on every status
Apache reads the 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.