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).

Apache configuration precedence and the onsuccess vs always tables Configuration merges from httpd.conf through VirtualHost and Directory to .htaccess, with later scopes overriding earlier; a side table shows that Header set writes only the onsuccess table while Header always set also writes the always table covering error responses. httpd.conf (server config, parsed once) <VirtualHost> (per host) <Directory> / <Location> .htaccess (per request) wins for its subtree later scope overrides earlier Emission table Header set X onsuccess only (2xx / 3xx) Header always set X always table: also 4xx / 5xx error pages keep headers only with always
Apache resolves config from httpd.conf down to .htaccess; the verb (set vs always set) decides whether a header survives error responses.

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

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.