Apache mod_headers Best Practices for Security
This reference drills into the mod_headers directive set and is a companion to Apache .htaccess & VirtualHost Hardening, which covers configuration precedence and scoping. Here the focus is the verb you write after Header — set, append, add, edit, unset, and the always/onsuccess selector — because choosing the wrong one is the most common reason a correct-looking security header fails to protect error responses or silently duplicates.
Configuration Syntax & Exact Values
The grammar is Header [onsuccess|always] <action> <name> ["<value>"]. The condition keyword defaults to onsuccess when omitted; the action determines how the value is written.
# onsuccess|always selects the response table; default is onsuccess (2xx/3xx only)
Header always set X-Content-Type-Options "nosniff" # replace on ALL responses
Header set X-Content-Type-Options "nosniff" # replace on success responses only <-- avoid
Header append Vary "Accept-Encoding" # comma-join onto an existing value
Header edit Set-Cookie "(.*)" "$1; Secure" # regex-rewrite an existing value
Header always unset X-Powered-By # remove on all responses (no value arg)
| Action | What it does | Use it for | Do NOT use it for |
|---|---|---|---|
set |
Replaces the header, creating it if absent | Single-valued headers (the normal case) | Anything where the error response must be covered (use always set) |
append |
Comma-joins a value onto an existing header of the same name | Genuinely list-valued headers like Vary |
CSP/HSTS — comma-joining corrupts the policy |
add |
Emits an additional header line of the same name | Almost never | Security headers — duplicate Content-Security-Policy lines are enforced as an intersection |
edit / edit* |
Regex-rewrites the value of an existing header | Surgical fixes (add Secure to cookies) |
Creating headers — edit no-ops if the header is absent |
unset |
Removes a header (name only, no value) | Stripping X-Powered-By, rollback |
— |
The decisive rule for security headers is always set. A bare set writes only to the onsuccess table, so the header is absent on 4xx/5xx and on Apache’s internally generated error documents — the responses an attacker most easily triggers and frames.
Server-Side Configuration
Define the headers once in the TLS <VirtualHost>, guarded by <IfModule> so a missing module degrades instead of crashing startup.
<VirtualHost *:443>
ServerName your-domain.com
DocumentRoot /var/www/html
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set X-XSS-Protection "0" # disables buggy legacy filter; CSP supersedes it
Header always unset X-Powered-By
</IfModule>
</VirtualHost>
Strict-Transport-Security rides only on the :443 host because compliant browsers ignore it over plaintext. The same block works verbatim in a .htaccess when VirtualHost access is unavailable, provided the host grants AllowOverride FileInfo — but that path adds a per-request filesystem walk, so prefer the VirtualHost. The header meanings are defined in the dedicated references: see the Content Security Policy essentials, the HTTP Strict Transport Security deep dive, and the Referrer-Policy and Permissions-Policy reference.
The Nginx equivalent of Header always set X "v" is add_header X "v" always; — note that Nginx’s add_header appends (it cannot replace), and that any add_header in a child location suppresses all inherited ones, which is the opposite of Apache’s accumulate-and-override model. That inheritance difference is detailed in the Nginx security headers configuration.
Diagnostic & Verification Steps
# Syntax must pass before any reload
apachectl -t
# Syntax OK
# Confirm the module is loaded (the IfModule guard hides its absence)
apachectl -M | grep headers
# headers_module (shared)
# Headers on a normal 200
curl -sI https://your-domain.com/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type'
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# content-security-policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'
# x-frame-options: DENY
# x-content-type-options: nosniff
# The test that matters: headers MUST persist on an error response
curl -sI https://your-domain.com/__missing__ | grep -i x-frame-options
# x-frame-options: DENY <-- present = "always" working; absent = you used plain "set"
apachectl -k graceful
If a header shows on the 200 line but vanishes on the __missing__ 404, the directive is using set rather than always set. In browser DevTools, request a deliberate 404 and confirm the Response Headers panel still lists every security header.
Edge Cases, Security Implications & Safe Rollback
-
alwaysvsonsuccess: the entire reason error pages lose protection. Treatalways setas mandatory for every security header; reserve plainonsuccessfor headers that are meaningless on errors (rare). -
Conditional headers with env vars: gate a header on a request condition by setting an environment variable and matching it. This avoids over-broad scoping:
SetEnvIf Request_URI "^/embed/" allow_framing Header always set X-Frame-Options "DENY" env=!allow_framingThe
env=!allow_framingclause emitsDENYeverywhere except the embed path, instead of duplicating the directive across<Directory>blocks. -
Duplicate merges:
mod_headersaccumulates across scopes, so the sameHeader always setin both the VirtualHost and a.htaccess(or viaHeader add) yields two header lines. For CSP, browsers enforce the intersection of all policies, silently blocking resources. Declare each security header in exactly one scope;unsetfirst in an inner scope if you must redefine it there. -
Reverse-proxy / CDN duplication: an upstream proxy or CDN may add its own copy.
Header always setreplaces rather than stacks at the origin, but it cannot remove a header injected downstream — reconcile that at the edge (see Cloudflare Page Rules & Headers). -
Safe rollback: comment the offending directive (or
Header always unset <Name>to neutralise it), runapachectl -t, thenapachectl -k graceful. Keep the config in version control so reverting is agit checkoutplus a graceful reload, with no dropped in-flight connections.
Frequently Asked Questions
When should I ever use Header set without always?
Only for a header that is genuinely irrelevant on error responses, which is rare. Every security header — CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy — must use always set so it covers 4xx/5xx and Apache’s internal error documents.
What is the difference between Header set and Header add for CSP?
set replaces, producing one header line. add emits a second Content-Security-Policy line, and browsers enforce the intersection of all CSP headers, which silently blocks resources. Use always set and never add for CSP.
Why use Header edit instead of set?
edit rewrites an existing value via regex without regenerating it — for example appending ; Secure to every Set-Cookie your backend emits. set would clobber the dynamic value. Note edit no-ops when the header is absent, so it is for modifying, not creating.
Does wrapping headers in <IfModule mod_headers.c> guarantee they apply?
No. The guard only prevents a startup crash if the module is missing; when mod_headers is not loaded the headers inside are silently skipped. Verify with apachectl -M | grep headers.
Conclusion
Standardise on Header always set in one authoritative scope — the TLS VirtualHost — guarded by <IfModule mod_headers.c>, and reserve append/edit/unset for their narrow purposes. Roll out incrementally: apply in staging, verify with curl -sI against both a 200 and a forced 404, then promote to production with apachectl -t followed by apachectl -k graceful.