Clickjacking and the Defensive Header Set: Frame-Ancestors, HSTS, and More

Understand how clickjacking tricks a user into clicking something they cannot see, then configure the full defensive response-header set in PHP: the frame-ancestors CSP directive and its older X-Frame-Options predecessor, HSTS to force HTTPS, X-Content-Type-Options to stop MIME sniffing, Referrer-Policy to control what leaks in the Referer header, and Permissions-Policy to disable browser features the page doesn't need.

A surprising amount of browser security doesn't live in your application logic at all -- it lives in a handful of response headers that tell the BROWSER how to treat your page. These headers are cheap to add, require no change to your business logic, and meaningfully reduce what an attacker can do even when some other part of the stack has a bug. This lesson works through clickjacking specifically -- a UI-level trick that framing headers exist to prevent -- and then the rest of the defensive header set that belongs on essentially every production response.

Clickjacking: Tricking a Click the User Never Meant to Make

Clickjacking loads your site inside an invisible or disguised <iframe> on an attacker-controlled page, then layers attractive-looking fake content on top of it -- a game, a 'claim your prize' button, an innocuous-looking video player. The user thinks they're clicking the fake content, but because the real site is rendered underneath (often with opacity set to near-zero via CSS), the click actually lands on YOUR page's button at that exact pixel position -- 'Transfer funds', 'Delete account', 'Grant this app access', whatever sensitive action the attacker has aligned their decoy with.

The critical property that makes this work is that the victim is genuinely, fully authenticated on your site in their own browser session -- the attacker isn't stealing credentials or a session cookie at all, they're hijacking a real click from a real logged-in user and redirecting where it lands. No XSS, no CSRF token bypass is needed, because the request that fires is a completely normal, same-origin, fully-authenticated request; only the visual context around the click was faked.

The defense is architectural rather than something application logic can catch: tell the BROWSER, via a response header, that your page must never be rendered inside a frame on another origin at all. If the browser refuses to display your page inside the attacker's iframe in the first place, there is nothing for the invisible-overlay trick to layer on top of.

• Clickjacking loads your site in an invisible/disguised iframe and tricks the user into clicking through to a real, sensitive action on your page. • The click that lands is a genuine, fully-authenticated, same-origin request -- no cookie theft or CSRF-token bypass required. • The defense is a response header that tells the browser your page may never be framed by another origin at all.

frame-ancestors and the Older X-Frame-Options

The modern, correct mechanism is the frame-ancestors directive of the Content-Security-Policy header: Content-Security-Policy: frame-ancestors 'self' tells the browser this page may only be framed by pages on the SAME origin, and frame-ancestors 'none' forbids framing entirely, from any origin including your own. It can also allowlist specific origins (frame-ancestors https://trusted-partner.example) for the legitimate case where an embed IS wanted from a known third party. frame-ancestors is a CSP directive, so it composes naturally with the rest of your Content-Security-Policy header rather than needing a separate mechanism.

The older, now largely superseded header is X-Frame-Options, with values DENY (never allow framing, from anywhere) or SAMEORIGIN (allow framing only from the same origin). It predates CSP and only supports those two useful values (plus a rarely-supported ALLOW-FROM that most modern browsers ignore) -- nowhere near as flexible as frame-ancestors' origin allowlisting. Despite being older, it's still worth sending ALONGSIDE frame-ancestors, not instead of it: a small number of older browsers and some browser extensions or embedded webviews still only respect X-Frame-Options, so sending both is a defense-in-depth belt-and-suspenders move that costs nothing.

• Content-Security-Policy: frame-ancestors 'self' -- allow framing only from your own origin; 'none' forbids it entirely. • X-Frame-Options: DENY or SAMEORIGIN -- the older, less flexible predecessor; still worth sending alongside frame-ancestors for legacy compatibility. • Send BOTH headers together -- frame-ancestors is authoritative in modern browsers, X-Frame-Options is the fallback for anything older. • If your app genuinely needs to be embeddable by a specific partner site, allowlist that exact origin rather than using 'self' with no restriction at all.

HSTS: Forcing HTTPS After the First Visit

Strict-Transport-Security (HSTS) tells the browser: for the next N seconds, NEVER attempt a plain-HTTP connection to this domain again, even if the user explicitly types http:// or clicks an http:// link -- silently upgrade every attempt to HTTPS before a single byte goes over the network. This closes a specific downgrade-attack window: without HSTS, even a site that always redirects HTTP to HTTPS still makes that FIRST request over plain HTTP before the redirect fires, and an attacker positioned on the network (a malicious Wi-Fi hotspot) can intercept that first plaintext request and strip the redirect, keeping the victim on HTTP indefinitely.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload sets a one-year enforcement window, extends it to every subdomain, and opts into the HSTS preload list -- a hardcoded list baked directly into major browsers so that even a user's very FIRST-ever visit to the domain (before it could have received the header at all) is forced to HTTPS from the start. Because HSTS is powerful and sticky (a mistake can lock users out of a domain over HTTP for a year), it should only be added once you're confident the whole domain and its subdomains genuinely support HTTPS everywhere.

• HSTS forces the browser to only ever connect over HTTPS for max-age seconds, even if the user explicitly requests http://. • It closes the 'first request is still plaintext' window that exists even on sites that always redirect HTTP to HTTPS. • includeSubDomains extends enforcement to every subdomain; preload opts into browsers' hardcoded HSTS list, covering even a first-ever visit. • Only add HSTS once HTTPS genuinely works across the whole domain -- it is difficult to safely walk back once browsers have cached it.

X-Content-Type-Options, Referrer-Policy, and Permissions-Policy

X-Content-Type-Options: nosniff disables a browser behavior called MIME sniffing, where the browser ignores the server's declared Content-Type and guesses the content type from the actual bytes instead. That sounds harmless, but it has historically enabled attacks where a file served AS one type (say, an uploaded image with attacker-controlled bytes) gets re-interpreted by the browser's sniffer as HTML or JavaScript and executed, even though the server explicitly labeled it as something inert. Sending nosniff tells the browser to trust the declared Content-Type and never override it -- directly relevant to the file-upload-serving controller covered earlier in this course, which should always send this header.

Referrer-Policy controls how much of the current page's URL gets sent in the Referer header when the user follows a link or an embedded resource loads from a different origin. A permissive default can leak sensitive information sitting in your own URLs -- a session token in a query string (which shouldn't be there in the first place, but defense in depth), a search query, an internal page path -- to every third-party resource and every external link's destination. Referrer-Policy: strict-origin-when-cross-origin is a sensible modern default: it sends the full URL for same-origin requests (where it's harmless) but only the origin (no path, no query string) for cross-origin requests, and nothing at all when downgrading from HTTPS to HTTP.

Permissions-Policy (formerly Feature-Policy) lets a page explicitly disable browser features it doesn't use -- camera, microphone, geolocation, fullscreen, payment APIs -- both for itself and for anything it embeds. Permissions-Policy: camera=(), microphone=(), geolocation=() disables all three entirely, which matters as defense in depth: if an XSS bug or a malicious embedded ad ever DID try to invoke the camera API, the browser refuses regardless, because the page has declared upfront that this feature is never needed.

• X-Content-Type-Options: nosniff -- stops the browser from re-guessing content type from bytes, closing a MIME-confusion attack class. • Referrer-Policy: strict-origin-when-cross-origin -- full URL on same-origin requests, origin-only cross-origin, nothing on an HTTPS-to-HTTP downgrade. • Permissions-Policy explicitly disables browser features (camera, microphone, geolocation) the page never needs -- a backstop even if another bug lets a script try to use them. • All of these headers are cheap, additive, and belong on essentially every response, not just pages that 'feel' security-sensitive.

Sending the Full Defensive Header Set in PHP

All header() calls must run before any output -- put them in a bootstrap file that executes before any echo/HTML. HSTS is only meaningful once served over HTTPS -- browsers ignore Strict-Transport-Security sent over plain HTTP. frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN are intentionally redundant -- modern browsers honor frame-ancestors, older ones fall back to X-Frame-Options.

// Run once, early, before any HTML output -- headers must precede the body.
header("Content-Security-Policy: frame-ancestors 'self'");
header('X-Frame-Options: SAMEORIGIN'); // legacy fallback, sent alongside CSP

header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');

header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');

Code Examples

Centralizing header logic in one function called from the front controller guarantees every response gets the same protection, rather than relying on each page to remember. HSTS is conditioned on the request actually being HTTPS, since sending it over plain HTTP has no effect and the check documents that intent explicitly.

header("Content-Security-Policy: {$frameAncestors}");

Common mistake: Setting these headers only on the login page or 'sensitive' pages -- clickjacking and MIME-sniffing attacks can target ANY page that performs a valuable action once the victim's click lands, not just obviously sensitive ones.

This 'frame-busting' script detects that the page is running inside another window and forces a top-level navigation to escape the frame. It is explicitly a FALLBACK: a sufficiently motivated attacker can neutralize simple frame-busting scripts with sandboxed iframe attributes that block top-level navigation, which is exactly why the frame-ancestors/X-Frame-Options headers -- enforced by the browser before the page's own JavaScript ever runs -- are the real, reliable defense. Never rely on this script alone; it exists to catch edge cases (very old proxies stripping headers) on top of the header-based defense, not to replace it.

function applySecurityHeaders(bool $allowEmbedFrom = false, ?string $partnerOrigin = null): void
{
    $frameAncestors = $allowEmbedFrom && $partnerOrigin !== null
        ? "frame-ancestors 'self' {$partnerOrigin}"
        : "frame-ancestors 'self'";

    header("Content-Security-Policy: {$frameAncestors}");
    header($allowEmbedFrom ? 'X-Frame-Options: ALLOW-FROM ' . $partnerOrigin : 'X-Frame-Options: SAMEORIGIN');

    if (($_SERVER['HTTPS'] ?? '') === 'on') {
        header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
    }

    header('X-Content-Type-Options: nosniff');
    header('Referrer-Policy: strict-origin-when-cross-origin');
    header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
}

// In the app's front controller, before any route handler runs:
applySecurityHeaders();
<?php
// Server-side header is the REAL defense. This inline script is a
// belt-and-suspenders fallback for any client that somehow ignores it.
?>
<script>
if (window.top !== window.self) {
    // This page is running inside a frame despite our header -- bail out.
    window.top.location = window.self.location;
}
</script>

Clickjacking Overlay, and How frame-ancestors Blocks It

The header is enforced by the browser before the page renders at all -- there is nothing left for the invisible-overlay trick to align against.

WITHOUT frame-ancestors                    WITH frame-ancestors 'self'
  ------------------------                    ---------------------------
  attacker.example                             attacker.example
  ┌──────────────────────────┐                 ┌──────────────────────────┐
  │ "Click to win a prize!"   │                 │ "Click to win a prize!"   │
  │ ┌───────────────────────┐│                 │                           │
  │ │ <iframe src=           ││                 │  [ BLANK -- browser      │
  │ │  victim-bank.example   ││                 │    refuses to render     │
  │ │  opacity: 0.01>        ││                 │    the frame at all,     │
  │ │  [Transfer Funds] btn  ││   ◀── click      │    because victim-bank's │
  │ │  invisibly aligned     ││       lands on   │    CSP forbids being     │
  │ │  under the fake button ││       REAL btn   │    framed by attacker.   │
  │ └───────────────────────┘│                   │    example ]             │
  └──────────────────────────┘                 └──────────────────────────┘

Important Rules

Send both Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN on every response, unless the page must genuinely be embeddable elsewhere.

Only enable HSTS once HTTPS works across the entire domain and its subdomains -- it is hard to safely reverse once cached by browsers.

Send X-Content-Type-Options: nosniff on every response, especially anything serving user-uploaded content.

Default to Referrer-Policy: strict-origin-when-cross-origin unless a specific page has a reason to leak less (or, rarely, more).

Disable browser features via Permissions-Policy that the page doesn't use, even if you don't think anything could currently invoke them.

Clickjacking in Practice

Real disclosed clickjacking issues have repeatedly targeted exactly the pages you'd expect to matter most and yet are easiest to forget: 'like' or 'follow' buttons, one-click purchase confirmations, OAuth 'authorize this app' consent screens, and account-settings toggles reachable with a single click and no additional confirmation step. The common thread in each fix reported publicly is the same one covered here -- the page in question was missing a framing-restriction header entirely, and adding frame-ancestors (or, on older stacks, X-Frame-Options) closed it without touching a single line of the vulnerable page's own logic.

Relying only on a client-side 'if (window.top !== window.self)' frame-busting script.

Send Content-Security-Policy: frame-ancestors 'self' (and X-Frame-Options as a fallback) as response headers.

A frame-busting script runs only after the page has already loaded inside the frame, and can be defeated by an attacker using a sandboxed iframe that blocks top-level navigation. A header is enforced by the browser before the page renders at all.

header('Strict-Transport-Security: max-age=31536000') sent unconditionally, including over plain HTTP requests.

Only send the HSTS header when the current request is actually being served over HTTPS.

Browsers ignore an HSTS header received over plain HTTP (it would be trivial for a network attacker to forge), so sending it unconditionally does nothing over HTTP and only adds noise -- worse, if HTTPS isn't fully ready sitewide, don't send it at all yet.

What makes a clickjacking attack work, structurally? A. The attacker steals the victim's session cookie via a network sniff B. The victim's real, authenticated site is rendered invisibly inside a frame, and a fake UI on top tricks their click into landing on it C. The attacker exploits a SQL injection vulnerability to forge a request D. The attacker guesses the victim's session ID through brute force

B. The victim's real, authenticated site is rendered invisibly inside a frame, and a fake UI on top tricks their click into landing on it — Clickjacking doesn't steal credentials or cookies at all -- it hijacks a genuine click from an authenticated user by visually misdirecting where that click actually lands, using an invisible iframe of the real site.

Why send both frame-ancestors (CSP) and X-Frame-Options together? A. They must always be sent together or neither works B. X-Frame-Options is required by the HTTP/2 specification C. frame-ancestors is authoritative in modern browsers; X-Frame-Options is a fallback for older clients that don't honor CSP framing directives D. frame-ancestors only works on Chrome, and X-Frame-Options only works on Firefox

C. frame-ancestors is authoritative in modern browsers; X-Frame-Options is a fallback for older clients that don't honor CSP framing directives — Sending both costs nothing and covers the small number of older browsers or embedded webviews that only respect X-Frame-Options, while modern browsers use the more flexible frame-ancestors.

What specific attack window does HSTS close that HTTP-to-HTTPS redirects alone do not? A. It prevents SQL injection on the login form B. It stops a network attacker from intercepting the FIRST plaintext HTTP request before any redirect to HTTPS fires C. It encrypts data stored in the database D. It prevents cross-site scripting in user-generated content

B. It stops a network attacker from intercepting the FIRST plaintext HTTP request before any redirect to HTTPS fires — Even a site that always redirects HTTP to HTTPS still makes that very first request in plaintext. HSTS tells the browser to upgrade to HTTPS before making any request at all, closing that window (fully, from the first visit, only when using the preload list).

What does X-Content-Type-Options: nosniff prevent? A. It prevents the browser from caching the response B. It stops the browser from guessing a different content type than the one the server declared, based on the file's bytes C. It disables all JavaScript execution on the page D. It forces all responses to be compressed with gzip

B. It stops the browser from guessing a different content type than the one the server declared, based on the file's bytes — Without nosniff, a browser may override a server's declared Content-Type based on sniffing the actual bytes -- which has enabled attacks where content served as an inert type gets reinterpreted and executed as HTML/JavaScript.

Write the applySecurityHeaders() logic needed for a page that legitimately must be embeddable in an iframe by exactly one trusted partner, https://partner.example, and by no one else -- including your own site's other pages if that's not otherwise needed. State both header values precisely.

Content-Security-Policy: frame-ancestors https://partner.example (omit 'self' entirely if your own other pages should not embed it either). X-Frame-Options has no clean equivalent for a single specific origin in modern browsers (ALLOW-FROM is deprecated and unsupported in most current browsers), so it is typically omitted or left as SAMEORIGIN with the understanding that frame-ancestors is the authoritative, enforced value in any modern browser.

frame-ancestors accepts a space-separated list of allowed origins instead of 'self' or 'none' -- think about what X-Frame-Options can and cannot express for this exact requirement.