Clickjacking deceives a user about which interface receives an action. A hostile parent can place a framed application behind a misleading visual layer so that a click reaches a real control in that application. The parent need not read the child’s DOM for the user to interact with it. Same-origin read restrictions therefore do not replace a decision about whether the application may be embedded at all.

The embedded response can declare Content-Security-Policy: frame-ancestors 'none' to disallow embedding, or use an explicit set of permitted ancestors. The named origins belong to the pages allowed to contain this document. If a merchant embeds a payment page, the payment page’s policy needs to permit the merchant’s origin; listing the payment widget’s own origin does not express that relationship.

Put each restriction on the right response

frame-ancestors answers who may embed the current document. frame-src answers which frame sources the current document may load. A restrictive frame-src on the child does not stop a different parent from embedding that child. Similarly, default-src 'none' does not supply a fallback for frame-ancestors. These directives control different directions of the relationship and should not be substituted merely because their names contain “frame.”

Frame-ancestors is not supported through a CSP meta element; deliver it as a response header. For nested embedding, every ancestor must match the policy, not just the immediate parent. A widget legitimately allowed inside one application can still be blocked when that application itself appears inside an unapproved outer frame. Review the complete intended embedding chain before choosing an allowlist.

When it breaks

A deployment may add the policy to the container page while leaving the sensitive child response unrestricted. Another may put it in HTML metadata and see a reassuring configuration string that the browser does not enforce. Verify the response actually being framed and test browser rendering. An HTTP 200 alone proves neither successful embedding nor the absence of frame protections: browsers can receive a document and refuse to display it in that context.

Conflicting headers can also mislead reviewers. In modern supporting browsers, an enforced frame-ancestors directive takes precedence over X-Frame-Options for that response. Pairing a permissive ancestor policy with X-Frame-Options: DENY does not necessarily produce the stricter result someone expects. Keep any deliberately retained header policy consistent with the intended audience and browser support, rather than assuming every restriction combines the same way as multiple CSP policies.

Check it quickly

Save this as frame-policy.py, run python3 frame-policy.py --serve, and open the printed parent URL. Python 3.14.4 and Chrome 152 were used. The parent and child servers occupy different loopback ports. Six child responses contain only synthetic text and a message indicating successful rendering. The parent accepts messages from the expected child origin and displays them. Control-C closes both temporary servers.

import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.request import urlopen

paths = ["open", "csp-block", "xfo-block", "csp-allow", "meta", "frame-src"]
requests = []


class Child(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        name = self.path.lstrip("/")
        assert name in paths
        requests.append(name)
        meta = ('<meta http-equiv="Content-Security-Policy" content="frame-ancestors \'none\'">'
                if name == "meta" else "")
        body = ("<!doctype html><meta charset=utf-8>" + meta + "<p>Rendered: " + name + "</p>"
                "<script>parent.postMessage({frame:" + json.dumps(name) + "}," + json.dumps(app_origin) + ");</script>").encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        if name == "csp-block":
            self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
        elif name == "xfo-block":
            self.send_header("X-Frame-Options", "DENY")
        elif name == "csp-allow":
            self.send_header("Content-Security-Policy", "frame-ancestors " + app_origin)
            self.send_header("X-Frame-Options", "DENY")
        elif name == "frame-src":
            self.send_header("Content-Security-Policy", "frame-src 'none'")
        self.end_headers()
        self.wfile.write(body)


class Parent(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        if self.path == "/events":
            body = json.dumps(requests).encode()
            kind = "application/json"
        else:
            body = ("""<!doctype html><meta charset="utf-8"><title>Frame policy lab</title>
<h1>Which child responses can this parent embed?</h1><pre id="result">Waiting for frame messages...</pre>
<button id="check">Show server requests</button><div id="frames"></div><script>
const child=""" + json.dumps(child_origin) + ";const names=" + json.dumps(paths) + """;
window.received=[];
window.snapshot=async()=>{
  const result={rendered:[...window.received].sort(),serverRequests:await (await fetch('/events')).json()};
  document.getElementById('result').textContent=JSON.stringify(result,null,2);return result;
};
addEventListener('message',event=>{
  if(event.origin===child && names.includes(event.data?.frame)) {
    window.received.push(event.data.frame);
    document.getElementById('result').textContent=JSON.stringify(window.received,null,2);
  }
});
document.getElementById('check').onclick=()=>window.snapshot();
for(const name of names) {
  const frame=document.createElement('iframe');frame.src=child+'/'+name;frame.title=name;
  frame.width=240;frame.height=100;document.getElementById('frames').append(frame);
}
</script>""").encode()
            kind = "text/html; charset=utf-8"
        self.send_response(200)
        self.send_header("Content-Type", kind)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


child = HTTPServer(("127.0.0.1", 0), Child)
app = HTTPServer(("127.0.0.1", 0), Parent)
child_origin = f"http://127.0.0.1:{child.server_port}"
app_origin = f"http://127.0.0.1:{app.server_port}"
threads = [Thread(target=server.serve_forever, daemon=True) for server in (child, app)]
for thread in threads:
    thread.start()
try:
    for name in paths:
        with urlopen(child_origin + "/" + name, timeout=5) as response:
            assert response.status == 200
            if name == "csp-allow":
                assert response.headers["Content-Security-Policy"] == "frame-ancestors " + app_origin
                assert response.headers["X-Frame-Options"] == "DENY"
            response.read()
    requests.clear()
    print(json.dumps({"app_url": app_origin, "child_origin": child_origin,
                      "wire_responses": 6}), flush=True)
    if "--serve" in sys.argv:
        Event().wait()
except KeyboardInterrupt:
    pass
finally:
    for server in (child, app):
        server.shutdown()
        server.server_close()
    for thread in threads:
        thread.join(timeout=5)

The open child renders. csp-block is refused by frame-ancestors none, and xfo-block is refused by X-Frame-Options DENY. The csp-allow child renders because its enforcing ancestor policy explicitly permits the parent, even though it also sends DENY. The meta child renders because that delivery mechanism cannot enforce frame-ancestors, while frame-src renders because its restriction governs frames the child would load, not this parent-child relationship.

Expect four rendering messages and six server requests. Click “Show server requests” to inspect both; the browser check also records two response-blocking failures for the denied frames. The default Python probe separately receives all six HTTP 200 responses, illustrating why header inspection alone cannot replace rendering checks. This experiment tests a single embedding level in one browser, not an account-action workflow or every nested integration. Before shipping, exercise legitimate embedding paths and blocked parents through the actual proxy or CDN, and verify that the intended child responses retain their policy.

Sources

  • MDN CSP frame-ancestors, allowed embedding parents, ancestor checks, and unsupported meta delivery, checked 2026-09-13.
  • MDN CSP frame-src, control over frames loaded by the current document, checked 2026-09-13.
  • MDN X-Frame-Options, DENY and SAMEORIGIN framing restrictions, checked 2026-09-13.
  • W3C CSP Level 3, frame-ancestors enforcement and its relationship with X-Frame-Options, checked 2026-09-13.