Clickjacking Defense in Practice: Configuring X-Frame-Options
Clickjacking overlays a transparent iframe to steal clicks. X-Frame-Options and CSP frame-ancestors are the cheapest defenses. This article gives copy-paste Nginx, Apache, Tomcat, and Spring Security configs, covers the always flag and header-merge pitfalls, and provides curl plus PoC-page checks.
Clickjacking defense: declare in response headers that the page must not be framed by third-party sites. X-Frame-Options and CSP frame-ancestors are the two widely supported implementations. This article starts from the attack, then gives copy-paste Nginx, Apache, Tomcat, and Spring Security configs, plus curl and PoC-page verification, so one config covers both old and modern browsers.
Scope
- Site has one-click high-risk actions: transfer, place order, delete data, authorize login, change password.
- CSRF already mitigated with
SameSite=Lax, but same-site framing or legacy-browser cookie delivery still exists. - A security assessment or compliance review flagged "missing Clickjacking protection headers."
- Pages are being iframed by third parties for ad hijack, traffic hijack, or click bait.
Prerequisites
- Permission to edit the web server config (Nginx / Apache / Tomcat) or an application interceptor.
- HTTPS enabled (recommended; stops middleboxes stripping headers).
curlavailable to check response headers.- Back up the original config. For Nginx,
nginx -tmust pass before reload.
Mechanism
Clickjacking needs three conditions at once: the target page can be iframed, the attacker controls the overlay visuals, and the victim is already authenticated. Typical pattern: a transparent iframe over a bait button:
<style>
iframe {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0.0001; /* fully transparent, invisible to the naked eye */
z-index: 9999; /* overlay on top of the bait element */
border: 0;
}
</style>
<button style="position:absolute;top:300px;left:200px;z-index:1">Click to claim coupon</button>
<iframe src="https://bank.example.com/transfer?amount=10000&to=attacker"></iframe>
The user sees "claim coupon"; the click lands on the target page under the transparent iframe. Defense: cut the first condition. Response headers tell the browser to forbid framing or restrict it to same-origin.
Header differences:
- X-Frame-Options: wide compatibility.
DENY(no framing),SAMEORIGIN(same-origin only).ALLOW-FROMis abandoned by major browsers; do not use it. - Content-Security-Policy: frame-ancestors: CSP Level 2. Supports allowlisted hosts and wildcards. Takes precedence over X-Frame-Options.
frame-ancestors 'none'≡ DENY;'self'≡ SAMEORIGIN.
Emit both for max compatibility: modern browsers honor CSP; old browsers fall back to X-Frame-Options.
Procedure
Step 1: Nginx response headers
Add in http, server, or location:
server {
listen 443 ssl http2;
server_name helixcyber.ai;
# fallback for old browsers
add_header X-Frame-Options "SAMEORIGIN" always;
# primary policy for modern browsers; replace 'self' with allowlisted domains as needed
add_header Content-Security-Policy "frame-ancestors 'self'" always;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
Key parameters: always is required; without it the header is omitted on status codes other than 200/204/301/302/304. If a Content-Security-Policy already exists, merge frame-ancestors into that same directive. Duplicate same-named headers: the browser takes the first and ignores the rest.
Validate and apply:
nginx -t
nginx -s reload
Step 2: Apache response headers
# Confirm the headers module is loaded
a2enmod headers
<VirtualHost *:443>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Content-Security-Policy "frame-ancestors 'self'"
</VirtualHost>
Syntax-check and reload:
apachectl configtest
systemctl reload apache2 # use httpd on CentOS/RHEL
Step 3: Tomcat filter
Tomcat 7+ ships HttpHeaderSecurityFilter. Add inside <web-app> in conf/web.xml:
<filter>
<filter-name>httpHeaderSecurity</filter-name>
<filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
<init-param>
<param-name>antiClickJackingEnabled</param-name>
<param-value>true</param-value>
</init-param>
<init-param>
<param-name>antiClickJackingOption</param-name>
<param-value>SAMEORIGIN</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>httpHeaderSecurity</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
Step 4: Application-layer declarative config (Spring Security)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
// emit CSP and X-Frame-Options separately to avoid directive conflicts
.contentSecurityPolicy(csp -> csp.policyDirectives("frame-ancestors 'self'"))
);
return http.build();
}
}
If the frontend must be framed by a named partner (e.g. a payment page), widen to an allowlist. Do not jump to DENY-off:
add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example.com; ..." always;
Step 5: Extra hardening — tighten cookie scope
Even with framing blocked, cut the odds of session cookies being sent cross-site:
Set-Cookie: SESSION=xxxx; Path=/; Domain=helixcyber.ai; Secure; HttpOnly; SameSite=Lax
Verification
Method 1 — confirm headers are emitted:
curl -sI https://helixcyber.ai/ | grep -iE 'x-frame-options|content-security-policy'
Expected:
x-frame-options: SAMEORIGIN
content-security-policy: frame-ancestors 'self'
Also confirm error pages carry the header (always actually works):
curl -sI https://helixcyber.ai/not-exist-page-404 | grep -i x-frame-options
Method 2 — local PoC page. Drop it in any site directory and open it:
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>clickjacking test</title></head>
<body>
<h3>If the target site is not visible in the area below, the defense is working</h3>
<iframe src="https://helixcyber.ai/" width="900" height="400"></iframe>
</body></html>
Browser console should show Refused to display 'https://helixcyber.ai/' in a frame because it set 'X-Frame-Options' to 'sameorigin'. That is a successful block.
Method 3 — bulk header check for regression:
while read -r p; do
printf '%s -> ' "$p"
curl -sI "https://helixcyber.ai$p" | grep -ic 'x-frame-options'
done < url_list.txt
FAQ
FAQ1: Both X-Frame-Options and CSP frame-ancestors are set. Which wins?
CSP Level 2 browsers honor only frame-ancestors and ignore X-Frame-Options on the same response. Old browsers that lack CSP read X-Frame-Options. Keep the two semantically identical so you do not get "allowed on new browsers, blocked on old." If you must pick one, modern sites prefer frame-ancestors: it supports a host allowlist instead of an all-or-nothing switch.
FAQ2: After config, some pages can still be framed. How to debug?
Work this order:
- The page is returned by the backend app and bypasses the Nginx
locationmatch. Check for a separate reverse-proxy rule or a separate port. - Another
Content-Security-Policyalready exists, soframe-ancestorsis overwritten or fails to merge.curl -sIshould show the same-named header once. - A child Nginx
locationalso hasadd_header, which drops parent header inheritance. Repeat the headers in the child. - CDN cached the old response. Purge and recheck. Static assets served from the CDN edge need the headers set there too.
FAQ3: DENY also blocked our own same-origin iframes. Now what?
Same-origin framing that is a business requirement should use SAMEORIGIN or frame-ancestors 'self'. Cross-origin but trusted framing uses an allowlist. Do not drop headers site-wide to unblock one page. Apply a stricter policy on /location for payment and admin paths; use an allowlist on ordinary content.
Summary
Clickjacking defense is cheap and high-yield: one response header closes the class. Rollout order: emit X-Frame-Options: SAMEORIGIN and Content-Security-Policy: frame-ancestors 'self' site-wide as baseline; widen only the paths that need cross-origin framing to an allowlist; put "header present?" on the release regression checklist. After config, verify with both curl and a PoC page, and confirm the CDN edge emits the same headers. Otherwise origin hardening is bypassed at cache.