Apache .htaccess & VirtualHost Hardening
This guide is part of the Server & Platform Implementation Guides reference and covers how Apache HTTP Server emits, merges, and scopes security response headers across its configuration hierarchy. Apache’s behaviour differs from Nginx in two ways that break naive deployments: header emission is gated by a verb (set vs always set) that decides whether the header survives error responses, and configuration is resolved through a merge chain (httpd.conf → <VirtualHost> → <Directory> → .htaccess) whose later stages can silently override earlier ones. Get either wrong and your headers will pass a curl -I against the homepage while vanishing on every 404 and 500 — exactly the responses an attacker probes.
Threat Model & Protocol Mechanics
Security response headers in Apache are produced by mod_headers. The module is not loaded in every distribution build, and when it is absent every Header directive is either ignored or — outside an <IfModule> guard — aborts startup with a syntax error. The first mechanic to internalise is therefore whether the module ran at all, and the second is which responses it touched.
Apache maintains two header tables per response: onsuccess (applied to 2xx and 3xx responses generated by the content handler) and always (applied to every response the connection emits, including handler-generated 4xx/5xx, internally generated error documents, and mod_proxy pass-through). A bare Header set X-Frame-Options DENY writes only to the onsuccess table. The consequence is concrete: a request that 404s, 500s, or is rejected by mod_security returns without your framing, MIME, or referrer protections. Browsers enforce policy per-response, so an error page rendered inside an attacker-controlled <iframe> is unprotected even though the homepage is locked down. Header always set writes to the always table and closes that gap. This is the single most important rule on this page.
| Threat Category | Primary Attack Vector | Apache Mechanism / Mitigation |
|---|---|---|
| Clickjacking on error pages | Framing a 404/403 that lacks framing controls | Header always set X-Frame-Options DENY plus CSP frame-ancestors |
| MIME confusion | Sniffing a misdeclared upload served via an error handler | Header always set X-Content-Type-Options nosniff |
| Protocol downgrade / SSL stripping | First-visit HTTP request before HSTS pins | Header always set Strict-Transport-Security in the :443 host only |
| Information disclosure | Server/X-Powered-By banners revealing version for CVE matching |
ServerTokens Prod, Header always unset X-Powered-By |
| Runtime config injection | Compromised app writing a malicious .htaccess |
AllowOverride None at the global scope |
| Cross-origin data leakage | Spectre-class side channels reading cross-origin responses | Header always set Cross-Origin-Opener-Policy/-Resource-Policy |
.htaccess vs VirtualHost: precedence and cost
.htaccess is a decentralised, per-directory override mechanism. When AllowOverride permits it, Apache walks the filesystem from DocumentRoot down to the requested file on every single request, stat()-ing and parsing a .htaccess in each directory it passes through. That I/O is unavoidable while overrides are enabled and is the reason the upstream documentation recommends AllowOverride None wherever you control the main config. VirtualHost and <Directory> directives, by contrast, are parsed once at startup and held in memory.
Precedence runs outermost-to-innermost: the main httpd.conf server config is the base, a matching <VirtualHost> merges over it, <Directory>/<Location>/<Files> sections merge in a defined order, and .htaccess is applied last — so a directive in .htaccess wins over the same directive set in the VirtualHost for that directory subtree. For mod_headers specifically, directives accumulate rather than replace across these scopes unless you explicitly unset, which is how duplicate headers appear (see Troubleshooting).
The headers themselves are defined by their own specifications, and this page focuses on the Apache delivery mechanism rather than re-deriving each policy. For the policy semantics, see the dedicated Content Security Policy reference, the HTTP Strict Transport Security deep dive, the cross-origin frame controls reference, and the Referrer-Policy and Permissions-Policy reference.
Directive Syntax & Spec
The mod_headers directive grammar is Header [condition] action header [[expr=]value]. The condition keyword is the always/onsuccess selector; omitting it defaults to onsuccess. The action is one of set, append, add, edit, edit*, unset, setifempty, echo, note, or merge.
# grammar: Header <onsuccess|always> <action> <header-name> "<value>"
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
| Directive form | Table | Default behaviour | Security Impact | When to Deviate |
|---|---|---|---|---|
Header set X |
onsuccess | Replaces header on 2xx/3xx only | Header missing on errors — bypass risk | Never for security headers |
Header always set X |
always | Replaces header on all responses | Correct baseline for every security header | Default; do not deviate |
Header append X |
onsuccess | Adds a comma-joined value to existing | Risks malformed duplicate policy strings | Only for genuinely list-valued headers |
Header add X |
onsuccess | Emits a second X header line |
Duplicate Content-Security-Policy = browser uses the most restrictive intersection unexpectedly |
Avoid; prefer set |
Header always unset X |
always | Removes header from all responses | Strips banners (X-Powered-By) and enables rollback |
Use to remove disclosure headers |
Header always edit X regex repl |
always | Rewrites an existing value via regex | Surgical fixes (e.g. add a Secure cookie flag) |
When you cannot regenerate the value |
Common malformed-syntax gotchas: quoting the whole set X "value" as one token (Header always set "X-Frame-Options DENY") creates a header literally named X-Frame-Options DENY; placing always after the action (Header set always) is a parse error; and using Header add for CSP produces two Content-Security-Policy response headers, which browsers combine by enforcing every policy simultaneously — almost never what you intended.
Platform-Specific Implementation
Place security headers in the <VirtualHost> for the TLS listener, guarded by <IfModule> so a missing module degrades gracefully instead of crashing startup. Every directive below uses always so the header is present on error responses too.
<VirtualHost *:443>
ServerName secure.example.com
DocumentRoot /var/www/app
Protocols h2 http/1.1
ServerTokens Prod # trims the Server banner to "Apache"
TraceEnable Off # disables HTTP TRACE (Cross-Site Tracing)
<IfModule mod_headers.c>
# always = emitted on 4xx/5xx and error documents too
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=(), payment=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set Cross-Origin-Embedder-Policy "require-corp"
Header always set X-XSS-Protection "0" # disables buggy legacy filter; CSP supersedes it
Header always unset X-Powered-By # strips framework version banner
</IfModule>
</VirtualHost>
Strict-Transport-Security belongs only in the :443 block: emitting it over plaintext HTTP is ignored by compliant browsers and risks pinning a non-TLS host. Verify the whole block in one shot:
curl -sI https://secure.example.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer|permissions|cross-origin'
When VirtualHost access is unavailable (shared hosting, a CMS tenant), the identical <IfModule mod_headers.c> block goes in a .htaccess at DocumentRoot. It requires the host to have set AllowOverride FileInfo (or broader) for that directory, and it incurs the per-request parse cost described above. The directive syntax is byte-for-byte the same — only the placement and performance profile change. For the deeper directive-by-directive reference see Apache mod_headers best practices for security, and for the CSP-specific traps of escaping policy strings in .htaccess see setting CSP in Apache .htaccess.
Teams running Apache behind a CDN or a reverse proxy should reconcile edge transforms with origin headers — duplication and stripping at the edge are covered in Cloudflare Page Rules & Headers — and teams migrating between web servers can map these directives to the Nginx security headers configuration.
Conditional and scoped variants
Tighten policy for a sensitive path using a nested <Directory> or an <If> expression. Because mod_headers accumulates, redeclare with always set (which replaces) rather than add to avoid stacking:
<Directory "/var/www/app/admin">
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; frame-ancestors 'none'"
Header always set Cache-Control "no-store"
</IfModule>
</Directory>
# Only attach HSTS preload once the connection is genuinely HTTPS
<If "%{HTTPS} == 'on'">
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
</If>
Verification & Diagnostic Workflows
Validate syntax before every reload, then prove the headers survive both success and error responses.
# 1. Syntax check — must print "Syntax OK" before any reload
sudo apachectl -t
# Syntax OK
# 2. Confirm mod_headers is actually loaded
apachectl -M | grep headers
# headers_module (shared)
# 3. Headers on a 200
curl -sI https://secure.example.com/ | grep -iE 'strict-transport|content-security|x-frame'
# 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
# 4. The decisive test — headers MUST persist on a 404 (proves "always")
curl -sI https://secure.example.com/does-not-exist | grep -i x-frame-options
# x-frame-options: DENY <-- present = always set is working
# 5. Graceful reload (finishes in-flight requests, no dropped connections)
sudo apachectl -k graceful
In browser DevTools, open the Network tab, request a known-404 URL, and confirm the Response Headers panel still lists the security headers — if they disappear on the 404 but appear on the 200, a directive is using set instead of always set. For CI/CD, fail the pipeline when the error-page check regresses:
#!/usr/bin/env bash
set -euo pipefail
hdr=$(curl -sI https://secure.example.com/__nope__ | grep -i '^x-frame-options' || true)
[ -n "$hdr" ] || { echo "FAIL: X-Frame-Options missing on 404 (use 'Header always set')"; exit 1; }
echo "OK: security headers present on error responses"
Troubleshooting, Misconfigurations & Safe Rollback
- Header present on the homepage, missing on 404/500 → fix: the directive uses
Header set; change it toHeader always set. Theonsuccesstable never touches error responses. .htaccessrules ignored entirely → fix: the parent<Directory>(or global default) hasAllowOverride None. Either grant the minimum class the rules need (AllowOverride FileInfocoversHeader) or, preferably, move the rules into the VirtualHost. Safe rollback for an unintended override: setAllowOverride Noneand the.htaccessis inert immediately on next request — no reload required.Invalid command 'Header'on startup → fix:mod_headersis not loaded. Runa2enmod headers && systemctl restart apache2(Debian/Ubuntu) or addLoadModule headers_module modules/mod_headers.so. Wrapping directives in<IfModule mod_headers.c>prevents the crash but silently skips your headers, so confirm withapachectl -M.- Two
Content-Security-Policylines in the response → fix: the value is being emitted in two scopes (e.g. VirtualHost and.htaccess) or viaHeader add. Browsers enforce the intersection of all CSP headers, which can block legitimate resources. Usealways setin exactly one scope;unsetfirst in inner scopes if needed. - Headers appear but with the wrong value after a deploy → fix: an inner
<Directory>or.htaccessis overriding the host. Trace merge order withapachectl -t -D DUMP_RUN_CFGand consolidate. - Immediate full rollback: comment the offending block (or remove the
.htaccess), runapachectl -tthenapachectl -k graceful, and re-run the curl error-page check. Keep the prior config under version control so reverting is a singlegit checkoutplus graceful reload rather than hand-editing.
Frequently Asked Questions
Why does Header always set matter more than the header value itself?
Because per-response enforcement is the browser’s model. A perfectly written CSP that only rides on 200 responses leaves every 404, 403, and 500 unprotected, and those are precisely the responses an attacker can often trigger and frame. always writes to the table Apache applies to all responses, including internally generated error documents.
Should I use .htaccess or the VirtualHost?
Use the VirtualHost whenever you control the main config. .htaccess forces a filesystem walk and parse on every request and is only justified when you cannot edit the server config (shared hosting, per-tenant CMS). The directive syntax is identical, so there is no security reason to prefer .htaccess — only an access constraint.
Does <IfModule mod_headers.c> make my config safe?
It only prevents a startup crash when the module is absent. If mod_headers is not loaded, every header inside the guard is silently skipped. Always confirm with apachectl -M | grep headers so the guard never masks a missing dependency in production.
How do I remove a leaked Server or X-Powered-By banner?
Set ServerTokens Prod (and ServerSignature Off) to trim the Server header, and use Header always unset X-Powered-By to drop the framework banner that backend apps add. These reduce the version fingerprint attackers use to match CVEs.
Why is HSTS only in the :443 block?
Sending Strict-Transport-Security over plaintext HTTP is ignored by compliant browsers and can pin a host that is not actually TLS-capable. Scope it to the TLS VirtualHost (or guard it with <If "%{HTTPS} == 'on'">) so the policy is only asserted on connections that can honour it.