← News
/PLAYBOOK · 2026-09-15

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

Prerequisites

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:

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:

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.