Skip to main content

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:

Attacker's malicious page
<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 PagePotential Damage
Banking transfer confirmationUnauthorized money transfers
Social media "Follow" or "Like"Inflated engagement, spam distribution
Account settings pagesEmail or password changes
Permission grant dialogsCamera, microphone, or notification access
One-click purchase buttonsUnauthorized purchases
Admin panel actionsUser 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 pageYour pageAllowed?
https://example.com/mainhttps://example.com/widgetYes
https://example.com/adminhttps://example.com/dashboardYes
https://evil.com/traphttps://example.com/checkoutNo
http://example.com/pagehttps://example.com/widgetNo (different scheme)
https://sub.example.com/pagehttps://example.com/widgetNo (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.

warning

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

FeatureX-Frame-OptionsCSP frame-ancestors
Block all framingDENYframe-ancestors 'none'
Allow same-origin framingSAMEORIGINframe-ancestors 'self'
Allow specific external domainALLOW-FROM (broken)frame-ancestors https://partner.com
Allow multiple external domainsNot possibleframe-ancestors https://a.com https://b.com
Allow all subdomains of a domainNot possibleframe-ancestors *.example.com
Browser supportUniversal (legacy)All modern browsers
Part of a broader security frameworkNo (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:

Recommended: Both headers for maximum protection
<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.

note

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:

Complete clickjacking protection
<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:

frame-ancestors as part of a broader CSP
<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>
caution

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.