Clickjacking Protection in .htaccess
Clickjacking is a deceptively simple attack that can trick your users into performing actions they never intended. An attacker loads your website inside an invisible iframe on their own malicious page, positions it over a tempting button like "Win a Prize," and when the user clicks, they are actually clicking a button on your site. That click could transfer money, change account settings, delete data, or grant permissions without the user ever realizing what happened.
This guide explains how clickjacking works, how to protect against it using the X-Frame-Options header, the differences between its available values, and how it compares to the modern CSP frame-ancestors directive.
What Is Clickjacking
Clickjacking (also called UI redress attack) exploits the browser's ability to layer web pages on top of each other using iframes. The attacker creates a page that loads your site in a transparent iframe positioned precisely over interactive elements on the attacker's visible page.
Here is a simplified example of how an attack works:
<html>
<head><title>You Won a Prize!</title></head>
<body>
<h1>Click the button to claim your free gift!</h1>
<button style="font-size: 24px; padding: 20px;">Claim Prize</button>
<!-- Your site loaded invisibly on top -->
<iframe src="https://your-bank.com/transfer?to=attacker&amount=1000"
style="opacity: 0; position: absolute; top: 0; left: 0;
width: 100%; height: 100%; z-index: 999;">
</iframe>
</body>
</html>
The user sees a "Claim Prize" button. But the invisible iframe containing your banking site is layered on top. When the user clicks what they think is the prize button, they are actually clicking the "Confirm Transfer" button on the bank's page. Because the user is already logged into the bank (their session cookie is sent automatically), the transfer goes through.
What Makes Clickjacking Dangerous
Several factors make clickjacking particularly effective:
- No special vulnerability needed. The attack does not require a bug in your code. It exploits normal browser behavior.
- The user is genuinely interacting. Unlike CSRF attacks that forge requests programmatically, clickjacking involves the user's real mouse click, which can bypass some anti-CSRF protections.
- Difficult to detect. The user sees the attacker's page, not yours. They have no visual indication that anything is wrong.
- Multiple clicks can be chained. Sophisticated attacks guide users through multi-step processes by moving the invisible iframe between clicks.
Common targets for clickjacking include:
| Target Page | Potential Damage |
|---|---|
| Banking transfer confirmation | Unauthorized money transfers |
| Social media "Follow" or "Like" | Inflated engagement, spam distribution |
| Account settings pages | Email or password changes |
| Permission grant dialogs | Camera, microphone, or notification access |
| One-click purchase buttons | Unauthorized purchases |
| Admin panel actions | User deletion, configuration changes |
X-Frame-Options Header
The X-Frame-Options HTTP response header is the traditional defense against clickjacking. It tells the browser whether your page is allowed to be displayed inside a <frame>, <iframe>, <object>, or <embed> element.
The header is set in .htaccess using mod_headers:
<IfModule mod_headers.c>
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
</IfModule>
The expression expr=%{CONTENT_TYPE} =~ m#text/html#i ensures the header is only added to HTML responses. There is no reason to send framing restrictions with images, CSS files, or API responses.
X-Frame-Options accepts three values. The header must contain exactly one of them.
DENY
<IfModule mod_headers.c>
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
</IfModule>
DENY is the strictest setting. The page cannot be displayed in a frame under any circumstances, not even by your own site. No origin, no domain, no exception.
When a browser encounters X-Frame-Options: DENY on a response loaded inside an iframe, it refuses to render the content and typically shows a blank frame or an error message.
Use DENY when:
- Your pages should never appear inside any iframe.
- You do not embed your own pages within your site using iframes.
- You want the simplest, most secure option.
This is the recommended default for most websites. The vast majority of web pages have no legitimate reason to be framed.
SAMEORIGIN
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
</IfModule>
SAMEORIGIN allows the page to be framed, but only by pages from the same origin (same scheme, host, and port). Pages from any other origin are blocked from framing your content.
| Framing page | Your page | Allowed? |
|---|---|---|
https://example.com/main | https://example.com/widget | Yes |
https://example.com/admin | https://example.com/dashboard | Yes |
https://evil.com/trap | https://example.com/checkout | No |
http://example.com/page | https://example.com/widget | No (different scheme) |
https://sub.example.com/page | https://example.com/widget | No (different host) |
Use SAMEORIGIN when:
- Your site uses iframes to embed its own pages (e.g., a dashboard that loads widgets in iframes, a page builder with preview frames).
- You need to frame your own content but want to prevent external sites from doing so.
ALLOW-FROM (Deprecated)
# DO NOT USE - Deprecated and poorly supported
Header always set X-Frame-Options "ALLOW-FROM https://trusted-partner.com"
ALLOW-FROM was intended to allow framing from a specific named origin. However, it has been deprecated and should not be used for the following reasons:
- Chrome never supported it.
- Firefox dropped support.
- Edge (Chromium-based) does not support it.
- Safari has inconsistent support.
- Only legacy versions of Internet Explorer and old Edge recognized it.
If you send ALLOW-FROM to a browser that does not support it, the header is ignored entirely, leaving your page with no clickjacking protection at all.
Never use ALLOW-FROM. It provides a false sense of security because most browsers ignore it, leaving your pages completely unprotected against clickjacking. If you need to allow framing from specific external domains, use the CSP frame-ancestors directive instead (covered in the next section).
Applying to Specific Pages
You may want different framing policies for different parts of your site. For example, your main pages should not be frameable, but a specific widget page needs to be embedded.
Block framing site-wide, then allow for specific files:
<IfModule mod_headers.c>
# Default: block all framing
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
# Allow framing for a specific embeddable widget
<Location "/embed/widget">
Header always set X-Frame-Options "SAMEORIGIN"
</Location>
</IfModule>
Or using <Files> for specific files:
<IfModule mod_headers.c>
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
<Files "embed.html">
Header always set X-Frame-Options "SAMEORIGIN"
</Files>
</IfModule>
Common Mistake: Forgetting the Content-Type Condition
Less optimal approach:
# Sent with EVERY response including images, CSS, JS, fonts...
Header always set X-Frame-Options "DENY"
While this is not technically harmful (browsers only apply X-Frame-Options to document loads, not subresources), it adds unnecessary bytes to every response. On high-traffic sites serving thousands of static assets, this overhead is wasteful.
Better approach:
# Only sent with HTML responses where framing is relevant
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
X-Frame-Options vs CSP frame-ancestors
The CSP frame-ancestors directive is the modern replacement for X-Frame-Options. It provides the same protection with significantly more flexibility.
Feature Comparison
| Feature | X-Frame-Options | CSP frame-ancestors |
|---|---|---|
| Block all framing | DENY | frame-ancestors 'none' |
| Allow same-origin framing | SAMEORIGIN | frame-ancestors 'self' |
| Allow specific external domain | ALLOW-FROM (broken) | frame-ancestors https://partner.com |
| Allow multiple external domains | Not possible | frame-ancestors https://a.com https://b.com |
| Allow all subdomains of a domain | Not possible | frame-ancestors *.example.com |
| Browser support | Universal (legacy) | All modern browsers |
| Part of a broader security framework | No (standalone header) | Yes (part of CSP) |
Equivalent Configurations
Block all framing:
<IfModule mod_headers.c>
# Legacy approach
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
# Modern approach (within a CSP header)
Header always set Content-Security-Policy "frame-ancestors 'none'"
</IfModule>
Allow same-origin only:
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
Header always set Content-Security-Policy "frame-ancestors 'self'"
</IfModule>
Allow specific external domains (only possible with CSP):
<IfModule mod_headers.c>
# X-Frame-Options cannot do this reliably
# Use CSP instead:
Header always set Content-Security-Policy "frame-ancestors 'self' https://trusted-partner.com https://another-partner.com"
</IfModule>
Should You Use Both?
Yes, use both headers together. This is the recommended approach for maximum compatibility:
<IfModule mod_headers.c>
# Modern browsers use frame-ancestors (takes precedence)
Header always set Content-Security-Policy "frame-ancestors 'none'"
# Legacy browsers fall back to X-Frame-Options
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
</IfModule>
When both headers are present, modern browsers use frame-ancestors and ignore X-Frame-Options. Older browsers that do not understand CSP use X-Frame-Options as a fallback. This layered approach ensures protection across all browser versions.
If the frame-ancestors directive and X-Frame-Options header conflict (e.g., frame-ancestors 'self' but X-Frame-Options: DENY), modern browsers follow frame-ancestors and ignore X-Frame-Options. For consistency and to avoid confusion, always make sure both headers express the same intent.
When frame-ancestors Is the Only Option
There are scenarios where X-Frame-Options simply cannot solve the problem:
Allowing multiple specific partners to embed your content:
<IfModule mod_headers.c>
Header always set Content-Security-Policy "frame-ancestors 'self' https://partner-a.com https://partner-b.com https://partner-c.com"
# X-Frame-Options set to SAMEORIGIN as best-effort fallback
# (legacy browsers won't allow the partners, but at least same-origin works)
Header always set X-Frame-Options "SAMEORIGIN" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
</IfModule>
Allowing all subdomains of a partner domain:
<IfModule mod_headers.c>
Header always set Content-Security-Policy "frame-ancestors 'self' https://*.partner-network.com"
</IfModule>
Complete Production Configuration
Here is a production-ready clickjacking protection setup:
<IfModule mod_headers.c>
# CSP frame-ancestors for modern browsers
Header always set Content-Security-Policy "frame-ancestors 'none'"
# X-Frame-Options for legacy browser support
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
</IfModule>
If you already have a broader CSP policy, add frame-ancestors to it rather than creating a second Content-Security-Policy header:
<IfModule mod_headers.c>
Header always set Content-Security-Policy "\
default-src 'self'; \
script-src 'self' https://cdn.example.com; \
style-src 'self' 'unsafe-inline'; \
frame-ancestors 'none'"
Header always set X-Frame-Options "DENY" "expr=%{CONTENT_TYPE} =~ m#text/html#i"
</IfModule>
Sending two separate Content-Security-Policy headers (one for frame-ancestors and one for everything else) is technically valid, but the browser applies both policies. The most restrictive combination wins. To avoid confusion and unintended interactions, always combine all your CSP directives into a single header value.
Clickjacking protection is straightforward to implement and has virtually no downside for the vast majority of websites. Set X-Frame-Options: DENY and frame-ancestors 'none' as your default, and only relax the policy for specific pages that genuinely need to be embedded. This simple header combination eliminates an entire class of attacks with a single line of configuration.