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 Headerset, 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.

Choosing the right mod_headers verb A decision flow: removing a header uses always unset; rewriting an existing value uses edit; list-valued headers use append; everything else uses Header always set, never plain set or add. Need to change a header? Remove it? Header always unset Rewrite value? Header edit (regex) List-valued? Header append Everything else: Header always set covers 4xx / 5xx — never plain set or add add = duplicate lines (avoid) set = success only (avoid for security)
Pick unset to remove, edit to rewrite, append for list headers; for every security header use Header always set.

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.

How the always and onsuccess tables diverge

Internally mod_headers maintains two separate output tables. The onsuccess table (the default when you omit a condition keyword) is merged into the response only when the request reaches the content handler and completes with a 2xx or 3xx status. The moment Apache short-circuits into an error path — a 404 for a missing file, a 403 from an Require directive, a 500 from a crashing script, or any response produced by an internal redirect to an ErrorDocument — it emits the response from the always table alone. A header written with plain set never entered that table, so it is simply not present on the bytes the client receives.

This is why the failure is invisible in casual testing: your homepage returns 200 and shows every header, so the config looks correct. Only a request that Apache turns into an error reveals the gap, and those are precisely the responses an attacker can force at will (a bogus path, a blocked method, a triggered exception) and then attempt to frame or MIME-confuse.

How the always and onsuccess response tables are applied A success response merges both the onsuccess and always header tables, while an error response merges only the always table, so headers written with plain set vanish on 4xx and 5xx responses. Client request Response status? 2xx / 3xx success onsuccess table written always table written All headers present set and always both ride 4xx / 5xx error onsuccess table skipped always table written Only always-set headers plain set is dropped here
Success responses merge both tables; error responses merge only the always table, so a header written with plain set disappears on every 4xx and 5xx.

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.

Extend the same check to a custom ErrorDocument. When you configure ErrorDocument 404 /404.html, Apache serves that page through an internal redirect, and the response still carries the original 404 status — so it, too, is an error response that only the always table reaches. Verify it explicitly, and expect the security headers to appear alongside the final status line:

# A custom ErrorDocument is still a 404 to mod_headers
curl -sI https://your-domain.com/__missing__ | grep -iE 'HTTP/|content-security-policy'
# HTTP/2 404
# content-security-policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'

# 403 from an access rule is another error path worth confirming
curl -sI https://your-domain.com/.git/config | grep -iE 'HTTP/|x-frame-options'
# HTTP/2 403
# x-frame-options: DENY

Edge Cases, Security Implications & Safe Rollback

How duplicate CSP declarations across scopes produce an enforced intersection A Content-Security-Policy declared in both the VirtualHost and a .htaccess accumulates into two header lines, and the browser enforces the intersection of both policies, silently blocking resources. VirtualHost scope Header always set CSP .htaccess scope Header always set CSP mod_headers accumulates lines Response headers CSP: default-src 'self' CSP: default-src 'self' two lines emitted Browser enforces the intersection of all CSP lines, so resources are silently blocked
Declaring CSP in two scopes emits two header lines; the browser enforces their intersection, blocking anything either policy leaves out.

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.