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?

frame-ancestors and sandbox tokensTry it yourself
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: DENY or SAMEORIGIN covers 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 both allow-scripts and allow-same-origin on your own origin.
  • Use the allow attribute 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-ancestors or X-Frame-Options).
  • Review every iframe in templates for sandbox, allow and title.

Key points

  • Protect sensitive pages with frame-ancestors (and X-Frame-Options as 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.