Web server

Content Security Policy on Apache

Apache keeps response headers in two tables. Which one a Header directive writes to decides whether your policy reaches redirects and error pages, and whether a response carries it once or twice.

A complete report-only config

One virtual host. The headers sit at virtual-host scope, not inside a <Location> or <Directory>, so the redirects and error pages Apache generates are covered too.

/etc/apache2/sites-available/example.com.conf
# Needs mod_headers (a2enmod headers) and mod_ssl. There is deliberately no
# <IfModule mod_headers.c> around the headers: without the module, you want
# configtest to fail on "Invalid command 'Header'", not a site with no policy.
<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/example.com

    SSLEngine on
    SSLCertificateFile    /etc/ssl/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/ssl/example.com/privkey.pem

    # "always" puts the header on redirects and error pages as well; plain
    # "Header set" skips both. The unset clears any copy in the other table -
    # from an application behind mod_proxy, say - which "always set" would
    # otherwise leave in place beside this one.
    Header unset Content-Security-Policy-Report-Only
    Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-uri https://your-ingest-code.ingest.us.headerhawk.com/r; report-to csp-endpoint"
    Header unset Reporting-Endpoints
    Header always set Reporting-Endpoints "csp-endpoint=\"https://your-ingest-code.ingest.us.headerhawk.com/r\""
</VirtualHost>

How this was checked: run in Docker on Apache httpd 2.4.68 with mod_ssl. httpd -t passes, both headers arrive on 200 and 404 responses, the same file without mod_headers fails configtest on Invalid command 'Header', and the browser applies the policy and posts a report for an inline script.

Gotcha

Header set skips redirects and error pages

The default table, onsuccess, is used for successful responses. always is used for every response, including the non-2xx ones Apache generates itself. A plain Header set writes to the default table.

# The default table: misses redirects and Apache's own error pages.
Header set Content-Security-Policy-Report-Only "default-src 'self'"

# The always table: every response, including those.
Header always set Content-Security-Policy-Report-Only "default-src 'self'"

So a 302 from a Redirect, and Apache's built-in 404 page, go out with no policy. A custom ErrorDocument can hide this: serving it is an internal request that runs your configuration again, so on httpd 2.4.68 a 404 with a local error page had the header and the same 404 without one did not.

Gotcha

Header always set adds a second policy beside a proxied one

Both tables go into the response, and a directive only touches its own. When an application behind mod_proxy sends a policy, it lands in the default table, and Header always set adds another in the always table.

ProxyPass /app/ http://backend/

# The backend's policy is in the default table, so /app/ gets two headers:
Header always set Content-Security-Policy-Report-Only "default-src 'self'"

# Clear the default table first, and /app/ gets exactly one:
Header unset Content-Security-Policy-Report-Only
Header always set Content-Security-Policy-Report-Only "default-src 'self'"

The browser applies both policies, so a resource must be allowed by each, and it reports against each. The symptom is a report naming a directive your policy allows, because the other policy does not. Clearing the default table before setting the always one, as the config does, leaves one policy.

.htaccess has the same trap with a delay. A Header set in the virtual host and a Header always set in .htaccess are one header in each table: two policies. And .htaccess is only read where AllowOverride includes FileInfo. Under AllowOverride None, a policy placed there is ignored without a line in the error log.

Now see what it's blocking

A report-only policy blocks nothing. When a browser loads a page and finds something the policy would have refused, it posts a report to the endpoint in report-uri and report-to. Until something listens there, those reports go nowhere.

HeaderHawk is one place to send them. It accepts both report formats on the same URL, groups them by directive and blocked host so a thousand reports about one script read as one issue, and keeps extension and bot reports visible but out of alerts. Replace your-ingest-code in the config above with the code from your site's setup panel, or paste the URL the panel shows.

The Apache integration guide covers that step: where the endpoint goes on Apache, and how to confirm the headers are being served.

A policy is the first of several controls browsers enforce on the code your pages load, including Integrity-Policy, Permissions-Policy, COOP and COEP, and they all report to the same endpoint. What client-side security is covers the rest of that layer.

Start reading the reports

Free for 1 site. Leave the policy in report-only until the reports go quiet, then enforce it.