Clickjacking and Framing
Your interface, rendered inside someone else's page, with their content on top. The defence is one response header — and the wrong version of it locks out keyboard users instead.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Who is allowed to put my page in a frame, and what can they do to a user who clicks in it?
A person clicks a button to approve something. They expect the click to go where the button appears to be, on the page they think they are on.
Nobody would frame our application, and if they did the same-origin policy would stop them reading anything, so there is nothing to steal.
Reading is not the goal. The attack is that the *user* acts: the click, the drag, the keystroke and the granted permission all happen in your real, authenticated page (The Same-Origin Policy).
- Reading is not the goal. The attack is that the *user* acts: the click, the drag, the keystroke and the granted permission all happen in your real, authenticated page (The Same-Origin Policy).
- Framing is allowed by default. A page refuses to be framed only if it says so in a response header, and silence is consent.
- The framing page controls the geometry — size, position, opacity, scroll offset, pointer events and what is layered on top — so it can present any part of your interface under any label it likes (Positioning and Stacking Contexts).
- The most valuable targets are single-click, irreversible actions: approve, delete, transfer, install, grant. Exactly the interactions a well-designed product makes easy to complete (DELETE: What Does Gone Mean?).
- JavaScript frame-busting has been unreliable for years. A sandboxed frame can block the navigation the busting script attempts, and the script runs after your page has already rendered.
What is actually happening
In the browser, not in the framework.
- A frame renders your document inside another document. The parent cannot read into it, and does not need to: it needs the user to interact with it while believing they are interacting with something else.
- Opacity and layering do the work. Your page can be transparent under a decoy, or the decoy can be transparent over your page. Either way the click lands in your document, in the user's session.
- The same idea extends beyond clicks: drag-and-drop to move content out, and keystroke redirection where a frame is focused so typed characters go somewhere unintended.
- `X-Frame-Options` is the older control, with
DENYandSAMEORIGIN. Its allowlist variant was inconsistently implemented and should not be relied on. - CSP `frame-ancestors` is the modern control. It accepts a source list, applies to nested framing, and where both are present it takes precedence in browsers that support it (Content Security Policy).
- Both are set on the framed page and enforced by the browser at load. There is nothing the framing page can do about a page that refuses.
- The `sandbox` attribute and permission delegation run the other way: they are how *you* constrain content you embed, rather than how you control who embeds you (Third-Party Scripts and the Supply Chain).
SameSitecookies interact with this usefully: aStrictorLaxsession cookie is withheld inside a cross-site frame, so the framed page often loads signed out — which blunts the attack as a side effect (Cross-Site Request Forgery).
What this makes the browser do
And which of it is avoidable.
- A framing check at document load: if the header forbids the current ancestor chain, the browser refuses to render the document at all (The Multi-Process Browser).
- Compositing and hit-testing across frame boundaries, which is how a click in a transparent overlay ends up dispatched into the framed document (How an Event Is Dispatched).
- A cross-origin frame is generally rendered in its own process, so the framing page cannot reach its memory any more than it can reach its DOM.
- Each frame is a full document: its own parse, its own style and layout, its own scripts. Framing your application inside another page duplicates all of it (The Frame Budget).
- Avoidable work: embedding a heavy first-party page in a frame when a component would do, paying for a second document to render one widget.
Your page, someone else's geometry
The mechanism is entirely about presentation. Your document loads normally, authenticates normally, and renders normally — inside a frame whose size, position, opacity and stacking the parent controls completely. The user sees the parent's content and clicks into yours.
Nothing is read and nothing is stolen in the usual sense. What is taken is the user's intent: they meant to click the thing they could see, and the click landed on the thing they could not.
- The framed document is fully authenticated unless
SameSitewithholds the cookie (Cookies). - The parent cannot read your DOM and does not need to — it only needs the click (The Same-Origin Policy).
- Drag gestures and keystrokes are targets too, not only clicks (Pointer Events).
- Single-click destructive actions are the highest-value targets, which is precisely what good UX produces (DELETE: What Does Gone Mean?).
The header, and the header you also still send
The defence is short enough to fit in a paragraph, which is why the interesting question is organisational rather than technical: who owns response headers, and does every environment actually send them? The application, the CDN and the static host each think they might be responsible.
Send both. frame-ancestors is the control that means what you want and takes precedence where supported; X-Frame-Options remains a cheap backstop. What you must not do is express an allowlist through the older header, whose allowlist variant was never widely implemented.
1# 1. Most applications: nothing should ever frame this.2Content-Security-Policy: frame-ancestors 'none'3X-Frame-Options: DENY4 5# 2. You frame your own pages (help panel, preview, embedded editor).6Content-Security-Policy: frame-ancestors 'self'7X-Frame-Options: SAMEORIGIN8 9# 3. Named partners may embed a specific, narrow surface.10# Only frame-ancestors can express this. X-Frame-Options cannot:11# its ALLOW-FROM variant was never implemented in Chromium or Safari.12Content-Security-Policy: frame-ancestors 'self' https://partner.example.com13 14# When you are the EMBEDDER, the controls run the other way:15# <iframe src="https://widget.vendor.example"16# title="Delivery tracking"17# sandbox="allow-scripts allow-forms"18# allow="">19# </iframe>20# - sandbox: start empty, add the minimum. Note that allow-scripts plus21# allow-same-origin together let the frame remove its own sandbox.22# - allow="": no camera, microphone, geolocation or payment delegated.23# - title: required. Without it the frame is an unnamed region.Header ownership is the real failure point. Assert these in a deployment smoke test — a hosting or CDN migration drops them silently and nothing in the application changes.
The confirmation that defends and the overlay that does not
Beyond the header, the second layer is interaction design: a destructive action that requires a deliberate, named confirmation cannot be completed by one misdirected click. That is a security control and a usability improvement at the same time, which is unusual and worth taking.
The mitigation to avoid is the hide-until-checked overlay. It is a focus trap by construction, it announces nothing, and when the check fails to complete the user is left on a page that is not merely broken but unreachable. Specify the confirmation properly instead.
semantics A native dialog element, or role="dialog" with aria-modal="true", labelled by its heading via aria-labelledby and described by the consequence text via aria-describedby.
| Tab / Shift+Tab | Cycles within the dialog only. Content behind it is inert, not merely visually covered. |
| Escape | Cancels and closes. Always available — a dialog with no escape is the failure mode this pattern is meant to avoid. |
| Enter | Activates the focused button. Never bind it to the destructive action by default. |
| Space | Activates the focused button, matching native button behaviour so nothing has to be relearned. |
- — On open, move focus into the dialog — to the cancel control, or to the heading. Never to the destructive button.
- — Make the rest of the document inert so a screen reader cannot wander out of the dialog into the page behind it.
- — On close, return focus to the control that opened the dialog, so the user resumes where they were.
- — If a typed confirmation is required, focus the input and label it with what must be typed, not with "Confirm".
- — The dialog title and description on open, so the object being acted on is named: "Delete invoice 4471", not "Are you sure?".
- — Whether the action is reversible, in the description rather than only in a colour or an icon.
- — The result after confirmation, in a live region, because the dialog that was announcing has now closed (Live Regions and Announcement).
usually broken by It is built as a styled div with a click-outside handler: focus is never moved in, the background stays reachable by Tab, Escape does nothing, and a screen-reader user is told nothing happened at all. The anti-framing variant of the same mistake is worse — an overlay that hides the page until a check completes, with no dismiss control and no announcement, which locks out exactly the users least able to work around it.
How to build it
Most important first.
- Send
frame-ancestorson every HTML response, defaulting to'none'or'self', and addX-Frame-Optionsalongside it for older clients (Deploying a Frontend). - Name the partners who may frame you explicitly and treat that list as reviewed configuration. "We might need it someday" is how a wildcard gets there.
- Do not implement frame-busting in JavaScript as the primary defence. It runs late, it can be blocked, and it introduces its own navigation hazards.
- Make destructive and irreversible actions require more than one click landing in the right place — a confirmation with the object named, a typed confirmation for the severe cases (Accessible Component Patterns).
- Use
SameSiteon session cookies so that a cross-site frame is unauthenticated to begin with (Cookies). - When you legitimately need to be embedded — a checkout, a widget, an SSO screen — build a narrow, purpose-specific document for that rather than framing the whole application.
- When *you* embed third-party content, use
sandboxwith the minimum set of tokens and an explicitallowlist for permissions (Images, Video and the Elements That Own Their Layout).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The classic mitigation is an accessibility failure. A script that hides the page body until a framing check passes leaves screen-reader users on a page announced as empty, and if the check fails silently they have no route forward at all (Live Regions and Announcement).
- Anti-framing overlays and interstitials trap focus. If you must show one, it needs a real dialog role, a reachable dismiss control, and focus returned where the user was (Accessible Component Patterns).
- A confirmation step is the accessible defence as well as the security one, provided it is a real dialog: labelled, focus-managed, escapable, and naming the object being acted on rather than saying "Are you sure?" (Keyboard Operability).
- Every legitimate frame needs a
title. An unlabelled frame is announced as an unnamed region, and a user must enter it blind to find out what it is (Semantics Before ARIA). - Keystroke redirection is an accessibility issue as much as a security one: keyboard and switch users are far more exposed to focus being moved out from under them than pointer users are (Focus Management).
What can go wrong
- No header at all, which is the default state of most applications and of essentially every internal tool.
SAMEORIGINwhereDENYwas meant, on an application that has no first-party framing at all — a needlessly wider surface for no benefit.- A
frame-ancestorslist that grew a wildcard for a partner integration and stayed permissive after that partner left. - The header set at the application and stripped or overwritten by a proxy, a CDN or a static host that manages headers separately (CDN Delivery).
- Frame-busting script that navigates the top window and breaks legitimate embedding — a documentation preview, an in-product help panel, an internal dashboard.
- The mitigation failing: an anti-framing overlay that covers the page until a check completes, which becomes a focus trap for keyboard users when the check never completes (Focus Management).
- A frame-busting or framing-check script that runs after paint leaves a window in which the page is visible and interactive inside the frame (The Rendering Opportunity).
- A page that navigates the top window on detecting a frame races the user's own click, which can send the click somewhere neither party intended (History and Navigation).
- Header configuration applied at a CDN and at the application can disagree during a deploy, so some responses carry the protection and some do not (CDN Delivery).
- The browser enforces
frame-ancestorsandX-Frame-Optionsabsolutely, at load, and the framing page has no way to override the refusal. - Neither header protects a page that does not send them, and neither is inherited — every document that can be navigated to needs its own.
- The attack needs no code execution in your origin and no credential theft. It borrows the user's authenticated session and their intent to click something.
SameSitecookies substantially reduce the value of framing your application, because the framed document often loads with no session at all (Cross-Site Request Forgery).- UI redress technique — cursor tricks, drag-based extraction, timing the overlay swap — belongs to Security Engineering; the frontend obligation is the header and the confirmation step (Attack Surface).
- "The same-origin policy stops framing." It stops the framing page reading your document. It does not stop the framing, and reading is not what the attack needs.
- "There is nothing sensitive on that page." A single-click destructive action is the target, and it is on the pages teams consider mundane.
- "We bust frames in JavaScript." That runs after your page renders and can be neutralised by a sandboxed frame. It is not a substitute for a header.",
- "
X-Frame-Options: SAMEORIGINandframe-ancestors 'self'are the same thing." They are close for the simple case and diverge on nested framing, and only one of them supports a source list you can trust. - "Only public sites need this." Internal tools are authenticated, destructive and unprotected, which is the worst combination available.
Measuring it, and what changes in the field
- Request your own pages and read the response headers. This takes seconds and is skipped remarkably often; the answer is frequently that no anti-framing header is present.
- Frame yourself in a scratch page. Either the browser refuses and logs why, or your application appears and you have your answer (A Mental Model of the Devtools).
- CSP violation reports include
frame-ancestorsviolations, which tells you who has actually been trying to embed you (Content Security Policy). - Assert the header in a deployment smoke test, because header configuration is exactly the kind of thing a hosting migration silently drops (Deploying a Frontend).
- Internal tools are the most exposed and the least protected: they are authenticated, they perform destructive actions, and nobody configures headers on them (Deploying a Frontend).
- On touch devices the equivalent attack uses taps and gestures, and the visual cues that might give it away are smaller (Pointer Events).
- If your product is meant to be embedded, the whole defence changes shape: you need a named allowlist and a design that assumes an unfamiliar visual context around it.
- In browsers where third-party cookies are already partitioned or blocked, a cross-site frame of your app is likely unauthenticated, which changes the risk substantially by browser (Storage Security and Durability).
DENYis the strongest and forecloses legitimate embedding — in-product help, partner integrations, documentation previews — until someone changes a header.- An allowlist keeps those integrations working and becomes a list that must be maintained, reviewed and pruned by someone.
- Confirmation steps defend against this and add friction to every legitimate use of the same action, so they belong on the destructive ones rather than everywhere.
- Being embeddable at all means designing for an unknown surrounding context: unknown width, unknown theme, and a focus order you do not own (Design Systems).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe framing model, the layering technique and the two header controls behave the same across Blink, Gecko and WebKit; a page that refuses to be framed refuses everywhere.
- BROWSER-SPECIFICThe
ALLOW-FROMvariant ofX-Frame-Optionswas never implemented in Chromium or Safari and is effectively dead, so an allowlist expressed that way protects in Firefox-era documentation and nowhere a user actually is — useframe-ancestorsfor any list. - SPEC-EVOLVINGThird-party cookie and storage partitioning changes are quietly reducing the value of framing an authenticated page, on different timelines per browser, so the residual risk of an unprotected page differs by which browser the victim uses.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — why an irreversible operation deserves a different interaction contract from a reversible one, all the way down to the command that expresses it.