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.

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

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.