Apache Clickjacking Protection
Setting X-Frame-Options and Content-Security-Policy frame-ancestors in Apache httpd.
Quick config
Apache sets response headers with the Header directive from mod_headers. RHEL and Fedora load it by default. Debian and Ubuntu ship it disabled, so enable it first.
# Modern -- CSP frame-ancestors (all current browsers)
Header always set Content-Security-Policy "frame-ancestors 'none'"
# Fallback -- X-Frame-Options (legacy browsers such as Internet Explorer)
Header always set X-Frame-Options "DENY"One header is enough for current browsers. Sending both is fine: when an enforced CSP has frame-ancestors, browsers ignore X-Frame-Options, and browsers without CSP support fall back to it. That also means the CSP decides: frame-ancestors * plus X-Frame-Options: DENY leaves the page frameable.
If you frame your own pages (dashboards, previews), use 'self' instead of 'none' and SAMEORIGIN instead of DENY.
Where to put it
Header is valid in the server config, a virtual host, a section such as <Directory>, and .htaccess. Use the one you can edit:
- Virtual host or server config: on Debian and Ubuntu, usually a file in
/etc/apache2/sites-available/; on RHEL and Fedora,/etc/httpd/conf/httpd.confor a file in/etc/httpd/conf.d/. The best choice if you have access: it is parsed once at startup, not on every request. .htaccess: a per-directory file for shared or managed hosting where you cannot edit the main config. Put it in your document root and it applies to that directory and everything below it.<Directory>or<Location>block: limits the header to part of the site, by filesystem path or URL path.
.htaccess requirements
Header in .htaccess needs AllowOverride FileInfo (or All) for that directory. The default is None, which ignores .htaccess files completely, so the header silently never appears. If you get a 500 Internal Server Error instead, the error log will say either Header not allowed here (AllowOverride lacks FileInfo) or Invalid command 'Header' (mod_headers is not loaded).
<VirtualHost *:443>
# ... your existing ServerName, DocumentRoot, TLS settings ...
# Let .htaccess files in this tree use Header
<Directory /var/www/html>
AllowOverride FileInfo
</Directory>
</VirtualHost>The always keyword
mod_headers writes to one of two internal header tables: onsuccess (the default when you omit the keyword) and always. Responses Apache generates itself with a non-2xx status, such as error pages (403, 404, 500, or a 502/503 when a proxied backend is down) and redirects, use only the always table. Headers set without always are missing from them.
# Wrong -- missing on Apache's own error pages and redirects
Header set X-Frame-Options "DENY"
# Right -- on every response, including errors
Header always set X-Frame-Options "DENY"Leaving out always is a common Apache misconfiguration. Use it for security headers.
The header table gotcha (duplicate headers)
always is not a superset of onsuccess. Apache keeps the two tables separately and merges them when it sends the response, so a header set in both tables is sent twice.
# Sends X-Frame-Options twice on successful responses
Header set X-Frame-Options "DENY"
Header always set X-Frame-Options "DENY"
# Better -- clear the onsuccess copy, set it once in always
Header onsuccess unset X-Frame-Options
Header always set X-Frame-Options "DENY"Two identical values are harmless in browsers, though scanners flag them. Different values are the real problem: conflicting X-Frame-Options values block all framing, including your own, and with two CSP headers the browser enforces both, so a frame must pass each one.
This mostly bites behind a backend that sets its own headers. Where those headers land depends on the module: CGI scripts and mod_proxy_fcgi (PHP-FPM) put them in the always table, while mod_proxy_http (Node.js, another Apache, any HTTP backend) puts them in onsuccess. A rule that touches only one table leaves the backend's copy in the other.
Pick one owner, Apache or the backend. If Apache owns these headers, clear the onsuccess copies and set yours in always (set replaces any existing value in the always table):
# Drop backend copies from onsuccess, then set our own in always
Header unset X-Frame-Options
Header unset Content-Security-Policy
Header always set Content-Security-Policy "frame-ancestors 'none'"
Header always set X-Frame-Options "DENY"If the backend sends a full CSP (script-src and so on), this replaces it with a policy that only has frame-ancestors. In that case, add frame-ancestors to the backend's policy instead.
Make sure mod_headers is loaded
RHEL, Fedora and their rebuilds load mod_headers by default. Debian and Ubuntu install it but leave it disabled. If Apache reports Invalid command 'Header', enable it:
# Debian / Ubuntu
sudo a2enmod headers
sudo systemctl restart apache2
# RHEL / Fedora -- loaded in /etc/httpd/conf.modules.d/00-base.conf:
# LoadModule headers_module modules/mod_headers.so
# Confirm it is loaded (should print headers_module)
sudo apachectl -M | grep headersVerify it is working
Run sudo apachectl configtest, reload Apache, then check the response headers:
curl -I https://yoursite.comLook for X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none', each exactly once.
Then request a URL that returns 404 to confirm always is working:
curl -I https://yoursite.com/nonexistentFinally, run the page through ClickJack Test to confirm it cannot be framed.
Notes on ALLOW-FROM
X-Frame-Options: ALLOW-FROM https://example.com is obsolete. Chrome and Safari never supported it, and current browsers, Firefox included, ignore an X-Frame-Options header that uses it, which leaves the page frameable. To allow a specific origin, use frame-ancestors https://example.com.