Modern HTML5 in Production · SEO, Sharing and Security
Clickjacking and Iframe Sandboxing
How an attacker tricked users into clicking 'Transfer' inside an invisible frame, and how frame-ancestors, sandbox and allow keep framing safe in both directions.
Scenario
Example app: a banking site's "confirm transfer" page can be loaded inside an iframe by any website. Separately, the bank's own help centre embeds third-party widgets and user-submitted HTML previews in iframes without restrictions.
What you see in production
- Customers report transfers they "didn't make". An attacker's page loaded the bank's confirmation page in a transparent iframe positioned under a "Claim your prize" button: clickjacking.
- A third-party widget in the help centre navigated the whole page to a phishing site.
The naive code
<!-- bank response: no frame protection header -->
<iframe src="https://widgets.example.net/help" width="400" height="300"></iframe>
<iframe srcdoc="{{ user_submitted_html }}"></iframe>
Reproduce it
The simulation evaluates two questions: may a page be framed by a given site (based on the response's frame-ancestors), and what can content inside a sandboxed frame do?
function mayFrame(frameAncestors, parentOrigin, pageOrigin) {
if (frameAncestors === null) return true; // no header: anyone can frame it
if (frameAncestors.includes("'none'")) return false;
return frameAncestors.some((src) => (src === "'self'" ? parentOrigin === pageOrigin : src === parentOrigin));
}
const bank = "https://bank.example.com";
for (const policy of [null, ["'self'"]]) {
for (const parent of ["https://bank.example.com", "https://evil.example.org"]) {
console.log(`frame-ancestors ${policy ? policy.join(" ") : "(none)"} | framed by ${parent}: ${mayFrame(policy, parent, bank) ? "allowed" : "blocked"}`);
}
}
function capabilities(sandbox) {
if (sandbox === null) return "everything (no sandbox)";
const can = [];
if (sandbox.includes("allow-scripts")) can.push("run scripts");
if (sandbox.includes("allow-forms")) can.push("submit forms");
if (sandbox.includes("allow-popups")) can.push("open pop-ups");
if (sandbox.includes("allow-top-navigation")) can.push("navigate the top page");
if (sandbox.includes("allow-same-origin")) can.push("act as its own origin (cookies, storage)");
return can.length ? can.join(", ") : "display only";
}
for (const sb of [null, [], ["allow-scripts"], ["allow-scripts", "allow-forms", "allow-popups"]]) {
console.log(`sandbox=${sb === null ? "(absent)" : `"${sb.join(" ")}"`}: ${capabilities(sb)}`);
}
Output
frame-ancestors (none) | framed by https://bank.example.com: allowed frame-ancestors (none) | framed by https://evil.example.org: allowed frame-ancestors 'self' | framed by https://bank.example.com: allowed frame-ancestors 'self' | framed by https://evil.example.org: blocked sandbox=(absent): everything (no sandbox) sandbox="": display only sandbox="allow-scripts": run scripts sandbox="allow-scripts allow-forms allow-popups": run scripts, submit forms, open pop-ups
Root cause
- Pages that perform sensitive actions didn't tell browsers who may frame them, so any site could overlay them.
- Embedded content ran with full capabilities: scripts, forms, pop-ups and top-level navigation.
The fix
- Send
Content-Security-Policy: frame-ancestors 'self'(or'none') on every page that shouldn't be embedded elsewhere.X-Frame-Options: DENYorSAMEORIGINcovers old browsers. See the OWASP clickjacking cheat sheet and MDN on frame-ancestors. - Embed third-party and user content with
sandbox, adding only the tokens it needs. Never give untrusted content bothallow-scriptsandallow-same-originon your own origin. - Use the
allowattribute to grant browser features (camera, fullscreen) explicitly, and serve user-generated HTML from a separate origin. - Require re-authentication or a confirmation step for sensitive actions.
Trade-offs
frame-ancestors 'self'breaks legitimate embedding by partners; list their origins explicitly instead of removing protection.- Sandboxing can break widgets that expect full access; test each token you add.
Detect it in production
- Security header scanners in CI (check every route returns
frame-ancestorsorX-Frame-Options). - Review every iframe in templates for
sandbox,allowandtitle.
Key points
- Protect sensitive pages with
frame-ancestors(andX-Frame-Optionsas a fallback). - Sandbox embedded content and grant only the capabilities it needs.
- Serve untrusted HTML from a separate origin.
Practice questions
What is clickjacking and how do you prevent it?
Answer
An attacker loads your page in a hidden or disguised iframe and tricks users into clicking controls on it. Prevent it by sending Content-Security-Policy: frame-ancestors 'self' (or 'none'), with X-Frame-Options as a legacy fallback.
What does the iframe sandbox attribute do?
Answer
It applies restrictions to the framed content: no scripts, forms, pop-ups, top-level navigation or same-origin access unless re-enabled with tokens such as allow-scripts or allow-forms.
Why is combining allow-scripts and allow-same-origin dangerous for same-origin content?
Answer
Scripts running with the page's own origin can reach into the parent frame and remove the sandbox attribute, defeating the sandbox entirely.
Open in the app to mark lessons complete, take notes and track your progress.