Verify Security Headers Are Actually Being Sent

Apache can accept a perfectly valid security configuration and deliver none of it. This is how to find out which, in one request.

The failure

Security headers are normally wrapped like this, and correctly so:

<IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
    Header always set Content-Security-Policy "default-src 'self'"
</IfModule>

The guard is necessary — without it, Apache refuses to start when the module is absent. The cost is that **when the module IS absent, the whole block silently does nothing**. The config is valid. Apache starts clean. apache2ctl configtest says Syntax OK. Nothing anywhere reports that half the file was skipped.

This site ran that way for its entire life. Every header was written, reviewed and committed, and not one of them was ever delivered.

Check the response, not the config

curl -sI https://example.com/ | grep -iE 'content-security-policy|x-frame-options|x-content-type|referrer-policy|permissions-policy'

Nothing back means nothing is being sent. Then:

apache2ctl -M | grep headers

If headers_module is missing:

sudo a2enmod headers
sudo systemctl reload apache2

A reload is enough. The restart the docs suggest is not needed.

The trap this leaves behind

A <meta http-equiv="Content-Security-Policy"> tag is a real, enforced policy, so a page with both a meta CSP and a header CSP keeps working when the header never arrives — which is why the outage was invisible. But **a meta-delivered CSP ignores frame-ancestors by specification**. If your clickjacking defence is frame-ancestors in the CSP plus X-Frame-Options in the same dead <IfModule> block, then both of your clickjacking defences are precisely the two that never shipped, while each one's comment reassures you the other has it covered.

Verify the hashes against what is served

If the CSP uses sha256- hashes for inline scripts, checking them against the repo proves nothing about the browser. Fetch the page, extract each inline <script> with no src, hash the served bytes, and match those against the hash list in the deployed header. A file deployed from a CRLF checkout hashes differently to the LF bytes your local tool reads.

The rule worth keeping

Every check that reads your source will pass while the running system is broken. At least one check has to read the response.