Web Security (OWASP Top 10:2025) Module 3 – A02:2025 Security Misconfiguration
Clickjacking and framing controls
What clickjacking is, which pages must refuse frames, and how frame-ancestors, X-Frame-Options, iframe sandbox and SameSite work together, tested in code.
What you will learn
- Explain clickjacking and decide which pages of an app must refuse to be framed
- Predict whether a browser renders a framed page from its frame-ancestors and X-Frame-Options headers and its ancestors
- Configure embedding on purpose for named origins only, and sandbox the third-party frames you embed
- Explain why frame-busting scripts and SameSite cookies are not enough on their own
Before you start
What you’ll learn · Before you start
What you will learn
- Explain clickjacking and decide which pages of an app must refuse to be framed
- Predict whether a browser renders a framed page from its frame-ancestors and X-Frame-Options headers and its ancestors
- Configure embedding on purpose for named origins only, and sandbox the third-party frames you embed
- Explain why frame-busting scripts and SameSite cookies are not enough on their own
Before you start
On this page
A page that sends no framing header can be shown inside any other site’s page. In this lesson’s simulator 6 of 16
setups let another site do exactly that, and 5 of those 6 carry a rule that was meant to stop it, such as
X-Frame-Options: ALLOW-FROM, a frame-ancestors rule in a meta element or a report-only policy.
clickjacking: Showing a page of one site inside a frame on another site, where the user does not notice it, lined up under something harmless, so that a real click or keystroke acts on the framed page. Also called UI redress (CWE-1021). turns such a frame into a real click on a button the user never saw.
You will decide which pages must refuse frames, predict the browser’s verdict for sixteen setups, let named partners embed a page on purpose, sandbox the frames you embed yourself, and see why frame-busting scripts and SameSite cookies cannot replace the headers.
Clickjacking turns a real click into an action the user never saw
Clickjacking, also called UI redress, needs only one thing from your site: that another site may show your page in a frame. MDN describes the rest. The other site’s page places your page where the user does not notice it, lined up with something harmless of its own, so that the user’s genuine click acts on your page. OWASP files the weakness under A06:2025 Insecure Design as CWE-1021, improper restriction of rendered UI layers or frames, and the fix is a header your server sends. Step through one such visit, then the same visit with the fix:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Another site's page. The user opens a page on other.example. It shows a button with a harmless label.
- Your page in a frame. The page shows your account page in a frame the user does not notice, lined up so that your Close account button sits where the decoy's button appears. Nothing tells the user that your page is there.
- A real click. The user clicks what they see. The click lands on your button inside the frame, because the frame is on top.
- A signed-in request. If the browser sent the user's cookies with the frame, your server receives a genuine click from a signed-in user and closes the account. It had no way to tell.
- The defence is in your headers. Now your page answers with frame-ancestors 'none' in its Content-Security-Policy header. The browser reads it before it shows anything in the frame.
- The frame stays empty. The browser refuses to render your page inside other.example's frame. The click lands on the decoy's own button, and your server receives nothing.
Nothing in the request told your server that the click came through someone else’s page. That is why the defence belongs in the response, before the browser shows anything: your page tells the browser who may frame it, and everyone else gets an empty frame.
Which pages need it? Every page where one click, a drag or a few keystrokes do something that matters: paying,
deleting, changing an email address or a password, granting an app access in an OAuth consent screen, signing in. In
practice that is most of a signed-in app, so the sane default is that no page may be framed, and embedding is an
exception you configure for the few pages that need it. ASVS 5.0.0 requirement 3.4.6 says the same: frame-ancestors
on every response, blocking embedding by default.
Key idea
Clickjacking needs only that another site may frame your page, so refuse framing by default and allow it only on the pages that must be embedded.
Checkpoint
Check: What clickjacking needs, and which pages to protect
frame-ancestors decides, X-Frame-Options is the fallback
Two headers control framing, and the browser reads them by rules that are easy to get wrong:
Content-Security-Policy: frame-ancestorslists who may frame the page:'none','self', origins such ashttps://partner.example, wildcard subdomains or a scheme. CSP Level 3 checks every frame ancestor: A document that contains a frame, directly or further up: the parent, its parent and so on to the top-level page. frame-ancestors and X-Frame-Options SAMEORIGIN check every one of them, not only the parent., the parent and each page above it to the top, and any one that does not match makes the browser refuse. The directive works only in an enforced header: it is ignored in ametaelement, and it does not fall back todefault-src.X-Frame-Optionsknows two values,DENYandSAMEORIGIN, and the HTML standard checksSAMEORIGINagainst every ancestor too. A browser reads it only when no enforcedframe-ancestorsexists: CSP Level 3 says the directive overrides the header. A lone unknown value,ALLOW-FROMincluded, counts as no header at all, and two different values make the browser refuse when one of them isDENYorSAMEORIGIN.
This program applies both rules to sixteen setups of one bank page. Each case names the headers and the chain of ancestors that frames it:
// mayRender(page, headers, ancestors) answers what a browser does when the page arrives in a frame: render it or
// refuse it. It follows two specifications: CSP Level 3 for frame-ancestors (every ancestor is checked; the directive
// counts only in an enforced header, never in a meta element or a report-only policy) and the HTML standard's check
// of X-Frame-Options, which applies only when no enforced frame-ancestors exists. Source matching covers the forms
// this lesson uses: 'none', 'self', https://host, https://*.host and the bare scheme https:.
const originOf = (url) => url.match(/^[a-z]+:\/\/[^/]+/)[0];
function matches(source, ancestor, self) {
if (source === "'self'") return ancestor === self;
if (/^[a-z]+:$/.test(source)) return ancestor.startsWith(`${source}//`);
const [scheme, host] = source.split('://');
const [aScheme, aHost] = ancestor.split('://');
if (scheme !== aScheme) return false;
if (host.startsWith('*.')) return aHost.endsWith(host.slice(1));
return aHost === host;
}
function mayRender(page, headers, ancestors) {
if (ancestors.length === 0) return ['render', 'not in a frame'];
const self = originOf(page);
const fa = /frame-ancestors([^;]*)/.exec(headers.csp ?? '');
if (fa) {
const sources = fa[1].trim().split(/\s+/);
for (const a of ancestors) {
if (sources[0] === "'none'" || !sources.some((s) => matches(s, a, self))) return ['refuse', `frame-ancestors does not allow ${a}`];
}
return ['render', headers.xfo ? 'frame-ancestors allows every ancestor; X-Frame-Options ignored' : 'frame-ancestors allows every ancestor'];
}
if (headers.xfo) {
const values = new Set(headers.xfo.split(',').map((v) => v.trim().toLowerCase()));
const known = ['deny', 'sameorigin', 'allowall'].some((v) => values.has(v));
if (values.size > 1) return known ? ['refuse', 'X-Frame-Options has conflicting values'] : ['render', 'X-Frame-Options has no valid value'];
if (values.has('deny')) return ['refuse', 'X-Frame-Options DENY'];
if (values.has('sameorigin')) {
const other = ancestors.find((a) => a !== self);
return other ? ['refuse', `SAMEORIGIN, and ${other} is an ancestor`] : ['render', 'SAMEORIGIN, every ancestor is the same origin'];
}
return ['render', `X-Frame-Options "${headers.xfo}" is ignored`];
}
const ignored = headers.cspMeta
? 'frame-ancestors in a meta element is ignored'
: headers.cspReportOnly
? 'report-only, so the frame is only reported'
: headers.csp
? 'no frame-ancestors, and default-src does not cover it'
: 'no framing control';
return ['render', ignored];
}
const BANK = 'https://bank.example/account';
const SELF = 'https://bank.example';
const OTHER = 'https://other.example';
const PARTNER = 'https://partner.example';
const cases = [
['no headers', {}, [OTHER]],
['XFO DENY', { xfo: 'DENY' }, [OTHER]],
['XFO DENY, own page frames it', { xfo: 'DENY' }, [SELF]],
['XFO SAMEORIGIN, own page', { xfo: 'SAMEORIGIN' }, [SELF]],
['XFO SAMEORIGIN, other site on top', { xfo: 'SAMEORIGIN' }, [SELF, OTHER]],
['XFO ALLOW-FROM partner', { xfo: 'ALLOW-FROM https://partner.example' }, [OTHER]],
['XFO SAMEORIGIN and DENY', { xfo: 'SAMEORIGIN, DENY' }, [SELF]],
["CSP 'none', own page", { csp: "frame-ancestors 'none'" }, [SELF]],
["CSP 'self' partner, partner", { csp: "frame-ancestors 'self' https://partner.example" }, [PARTNER]],
['CSP partner, with XFO DENY', { csp: "frame-ancestors 'self' https://partner.example", xfo: 'DENY' }, [PARTNER]],
['CSP partner, other on top', { csp: 'frame-ancestors https://partner.example' }, [PARTNER, OTHER]],
["CSP 'none' in a meta element", { cspMeta: "frame-ancestors 'none'" }, [OTHER]],
["CSP 'none', report-only", { cspReportOnly: "frame-ancestors 'none'" }, [OTHER]],
["CSP default-src 'none' only", { csp: "default-src 'none'" }, [OTHER]],
['CSP *.bank.example, bank.example', { csp: 'frame-ancestors https://*.bank.example' }, [SELF]],
['CSP https:, any https site', { csp: 'frame-ancestors https:' }, [OTHER]],
];
console.log(`The page: ${BANK}. Ancestors: the parent first, then up to the top.`);
const open = [];
cases.forEach(([name, headers, ancestors], i) => {
const [verdict, why] = mayRender(BANK, headers, ancestors);
if (verdict === 'render' && ancestors.some((a) => a === OTHER)) open.push(i + 1);
console.log(`${String(i + 1).padStart(2)}. ${name.padEnd(35)}${verdict.padEnd(8)}${why}`);
});
console.log(`Rendered inside a page of other.example: cases ${open.join(', ')}`); Output
The page: https://bank.example/account. Ancestors: the parent first, then up to the top. 1. no headers render no framing control 2. XFO DENY refuse X-Frame-Options DENY 3. XFO DENY, own page frames it refuse X-Frame-Options DENY 4. XFO SAMEORIGIN, own page render SAMEORIGIN, every ancestor is the same origin 5. XFO SAMEORIGIN, other site on top refuse SAMEORIGIN, and https://other.example is an ancestor 6. XFO ALLOW-FROM partner render X-Frame-Options "ALLOW-FROM https://partner.example" is ignored 7. XFO SAMEORIGIN and DENY refuse X-Frame-Options has conflicting values 8. CSP 'none', own page refuse frame-ancestors does not allow https://bank.example 9. CSP 'self' partner, partner render frame-ancestors allows every ancestor 10. CSP partner, with XFO DENY render frame-ancestors allows every ancestor; X-Frame-Options ignored 11. CSP partner, other on top refuse frame-ancestors does not allow https://other.example 12. CSP 'none' in a meta element render frame-ancestors in a meta element is ignored 13. CSP 'none', report-only render report-only, so the frame is only reported 14. CSP default-src 'none' only render no frame-ancestors, and default-src does not cover it 15. CSP *.bank.example, bank.example refuse frame-ancestors does not allow https://bank.example 16. CSP https:, any https site render frame-ancestors allows every ancestor Rendered inside a page of other.example: cases 1, 6, 12, 13, 14, 16
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node framing_policy.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Six setups let other.example render the page. Case 1 sends nothing. Case 6 meant to allow one partner with
ALLOW-FROM, which browsers no longer understand, so the header vanished and the page opened to everyone: OWASP
calls this failing open. Case 12 put the rule in a meta element and case 13 in a report-only policy, which reports
and never blocks. Case 14 relied on default-src 'none', which says nothing about framing, and case 16 allowed the
scheme https:, which is every site on HTTPS. The refusals are as instructive: case 5 failed because a stranger sat
above a same-origin parent, and case 10 shows frame-ancestors winning over DENY.
A report-only policy next to DENY
Press Edit and add ['XFO DENY with CSP report-only', { xfo: 'DENY', cspReportOnly: "frame-ancestors 'none'" }, [OTHER]],
to cases, then run it. The page is refused, and only because of DENY: the report-only policy reports and decides
nothing, so X-Frame-Options still counts.
Step through cases 10 and 5 as the browser does:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Case 10 arrives. The bank's account page arrives in a frame on partner.example. Its response has frame-ancestors 'self' https://partner.example and X-Frame-Options DENY.
- An enforced policy. The policy is an enforced header, not a meta element or a report-only policy, so frame-ancestors decides and X-Frame-Options is set aside.
- Every ancestor. The browser checks each ancestor against the list, here only the parent, partner.example, which is listed. Had another site framed the partner's page, that site would have to be listed too.
- Rendered. Every ancestor matched, so the page renders, and the DENY is never read. Browsers that support frame-ancestors ignore X-Frame-Options when both are sent.
- Case 5 arrives. Another page arrives in a frame, with only X-Frame-Options SAMEORIGIN. Its parent is a bank page, and above that sits a page of other.example.
- No frame-ancestors. There is no enforced frame-ancestors, so the browser falls back to the HTML standard's check of X-Frame-Options.
- One value, every level. The header holds one valid value, SAMEORIGIN, so every ancestor up to the top must be the page's own origin. The parent is; other.example at the top is not.
- Refused. One ancestor failed, so the browser refuses to render the page. A same-origin parent does not help when a stranger sits above it.
Browsers have supported frame-ancestors for years, Chrome since version 40, Edge since 15, Firefox fully since 58 and
Safari since 10, by MDN’s compatibility data. MDN still advises sending X-Frame-Options as well, for browsers that
lack the directive; the current ones ignore it once frame-ancestors is there.
The rule in a meta element
<meta http-equiv="Content-Security-Policy" content="frame-ancestors 'none'"> does nothing: the specification
says the directive must be ignored when it arrives in a meta element, as case 12 showed. Send it as a response
header, and test the header, not the page source.
Checkpoint
Check: How frame-ancestors and X-Frame-Options decide
Embedding on purpose: its own origin and named ancestors
Some pages must be embedded: a support widget, a booking form on partners’ sites, a status badge. Treat the
embeddable part as a small app of its own. Serve it from an origin of its own, such as widget.example.com, so that
the rest of the app keeps frame-ancestors 'none'; list the partners by origin, frame-ancestors https://partner-a.example https://partner-b.example; and keep what the embedded page can do small. An action that
matters, such as paying or changing an account, should need a step the frame cannot click for the user, for example
signing in again in a new window of its own.
Wildcards need care. https://*.example.com matches every subdomain but not example.com itself, which OWASP’s
cheat sheet points out and case 15 of the simulator showed, so the bare host needs an entry of its own. A bare scheme
such as https: or a * allows the whole web. And X-Frame-Options cannot list several origins at all; ASVS 5.0.0
calls it obsolete and asks for frame-ancestors instead. Which setup fits a page depends on two questions about it.
Answer them for a page of your own app:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Setup | Keeps other sites out | Lets your own pages in | Lets named partners in | Holds in old browsers |
|---|---|---|---|---|
| frame-ancestors 'none' + DENY May frame the page: no page at all | Good refused everywhere, even by your own pages (case 8) | Poor your own pages are refused too | Poor partners are refused | Good DENY covers browsers without frame-ancestors |
| 'self' + SAMEORIGIN May frame the page: pages of the same origin, at every level | Good another origin at any level is refused (case 5) | Good pages of the same origin may frame it | Poor a partner is another origin | Good SAMEORIGIN keeps other sites out; every ancestor since Chrome 61 and Firefox 59 |
| Named origins, no XFO May frame the page: the listed origins, at every level | Good only the list, and every ancestor is checked (case 11) | Mixed only if 'self' is in the list | Good each partner origin by name (case 9) | Poor without X-Frame-Options, an old browser lets any site frame it |
| frame-ancestors https: May frame the page: any page served over HTTPS | Poor any site on HTTPS may frame it (case 16) | Good your pages are on HTTPS too | Good and so is everyone else | Poor nothing for old browsers |
| No framing header May frame the page: any page | Poor any site may frame it (case 1) | Good anyone may | Good anyone may | Poor nothing for any browser |
How the answers decide
- “Which pages need to show this page in a frame?” None, it is never embedded: adds a caveat to 'self' + SAMEORIGIN and Named origins, no XFO. These allow frames that nobody needs. Prefer 'none' with DENY, so even a page of your own cannot frame it.
- “Which pages need to show this page in a frame?” Only pages of its own origin: rules out frame-ancestors 'none' + DENY. 'none' refuses your own pages as well, so the page that needs to frame it breaks.
- “Which pages need to show this page in a frame?” Named partners' sites: rules out frame-ancestors 'none' + DENY and 'self' + SAMEORIGIN. A partner is another origin, so both refuse it. Name each partner in frame-ancestors instead.
- “Which pages need to show this page in a frame?” Named partners' sites: adds a caveat to Named origins, no XFO. Old browsers that know only X-Frame-Options let any site frame the page, so keep its actions behind a confirmation the frame cannot click for the user.
- “Does the page let one click do something that matters, such as pay, delete or grant consent?” Yes: rules out frame-ancestors https: and No framing header. Any site could frame the action and place it under a decoy, which is exactly what clickjacking needs.
In short: 'none' with DENY for pages nothing needs to embed, 'self' with SAMEORIGIN for pages your own app
frames, named origins without X-Frame-Options for partners, and never a scheme or nothing on a page with actions.
Checkpoint
Check: Embedding on purpose
Sandbox the frames you embed
The same thinking applies in the other direction, when your page embeds someone else’s: a map, a video, an ad, a
preview of HTML that a user wrote. The iframe sandbox: The sandbox attribute of an iframe. With no tokens it takes away scripts, forms, pop-ups, navigation of the top page and the frame's own origin; each token, such as allow-scripts, gives one power back. of iframe takes powers
away from the framed page, and
with no tokens at all MDN says it applies every restriction: no scripts, no forms, no pop-ups, no navigation of your
page, and an origin of its own that matches nothing. Each token gives one power back, so give the fewest that the
content needs. This program checks seven embeds of one shop by those rules:
// What an iframe you embed may do, worked out from its sandbox tokens as MDN describes them, and what a check of the
// embed should flag. The embeds are data here; in a project the same check runs over the iframes of your templates.
function powers(tokens) {
const has = (t) => tokens.includes(t);
return {
scripts: has('allow-scripts'),
forms: has('allow-forms'),
popups: has('allow-popups'),
ownOrigin: has('allow-same-origin'),
navigateTop: has('allow-top-navigation') ? 'any time' : has('allow-top-navigation-by-user-activation') ? 'after a click' : 'never',
};
}
function flags(embed) {
const found = [];
const thirdParty = embed.src !== embed.page;
if (embed.sandbox === null) {
if (thirdParty) found.push('no sandbox: it keeps every power of its own origin');
} else {
const tokens = embed.sandbox.split(/\s+/).filter(Boolean);
const p = powers(tokens);
if (p.scripts && p.ownOrigin && !thirdParty) found.push('same origin with allow-scripts and allow-same-origin: it can remove its own sandbox');
if (p.navigateTop === 'any time') found.push('allow-top-navigation: it can send your page elsewhere at any moment');
if (tokens.includes('allow-popups-to-escape-sandbox')) found.push('its pop-ups run without the sandbox');
}
if (embed.userContent && !thirdParty) found.push('user content on your own origin: opened outside the frame, it runs unsandboxed');
for (const feature of (embed.allow ?? '').split(/[;\s]+/).filter(Boolean)) {
if (['camera', 'microphone', 'geolocation', 'payment'].includes(feature)) found.push(`allow grants ${feature}`);
}
return found;
}
const PAGE = 'https://shop.example';
const embeds = [
{ name: 'map, as pasted from the docs', src: 'https://maps.example', page: PAGE, sandbox: null, allow: 'geolocation' },
{ name: 'map, sandboxed', src: 'https://maps.example', page: PAGE, sandbox: 'allow-scripts allow-same-origin', allow: '' },
{ name: 'user HTML preview, own origin', src: PAGE, page: PAGE, sandbox: 'allow-scripts allow-same-origin', allow: '', userContent: true },
{ name: 'user HTML preview, scripts only', src: PAGE, page: PAGE, sandbox: 'allow-scripts', allow: '', userContent: true },
{ name: 'user HTML preview, own host', src: 'https://usercontent.example', page: PAGE, sandbox: 'allow-scripts', allow: '', userContent: true },
{ name: 'ad slot, generous', src: 'https://ads.example', page: PAGE, sandbox: 'allow-scripts allow-top-navigation allow-popups allow-popups-to-escape-sandbox', allow: '' },
{ name: 'ad slot, tight', src: 'https://ads.example', page: PAGE, sandbox: 'allow-scripts allow-popups allow-top-navigation-by-user-activation', allow: '' },
];
let flagged = 0;
for (const e of embeds) {
const found = flags(e);
if (found.length) flagged++;
const p = e.sandbox === null ? null : powers(e.sandbox.split(/\s+/));
const what = p
? `scripts ${p.scripts ? 'yes' : 'no'}, forms ${p.forms ? 'yes' : 'no'}, own origin ${p.ownOrigin ? 'yes' : 'no'}, navigates your page ${p.navigateTop}`
: 'no sandbox';
console.log(`${e.name}\n ${what}`);
for (const f of found.length ? found : ['nothing to flag']) console.log(` - ${f}`);
}
console.log(`${flagged} of ${embeds.length} embeds flagged`); Output
map, as pasted from the docs no sandbox - no sandbox: it keeps every power of its own origin - allow grants geolocation map, sandboxed scripts yes, forms no, own origin yes, navigates your page never - nothing to flag user HTML preview, own origin scripts yes, forms no, own origin yes, navigates your page never - same origin with allow-scripts and allow-same-origin: it can remove its own sandbox - user content on your own origin: opened outside the frame, it runs unsandboxed user HTML preview, scripts only scripts yes, forms no, own origin no, navigates your page never - user content on your own origin: opened outside the frame, it runs unsandboxed user HTML preview, own host scripts yes, forms no, own origin no, navigates your page never - nothing to flag ad slot, generous scripts yes, forms no, own origin no, navigates your page any time - allow-top-navigation: it can send your page elsewhere at any moment - its pop-ups run without the sandbox ad slot, tight scripts yes, forms no, own origin no, navigates your page after a click - nothing to flag 4 of 7 embeds flagged
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sandbox_check.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Four were flagged. The map as pasted from a provider’s docs had no sandbox and granted geolocation through allow,
the attribute that sets the frame’s Permissions Policy, within what the page itself is allowed. The user’s HTML
preview served from the shop’s own origin with allow-scripts allow-same-origin is the combination MDN strongly
discourages: the framed document can remove its own sandbox, which leaves it no safer than no sandbox at all. Even
with scripts only it was flagged, because a page on your own origin that a user can open directly runs with no
sandbox; the fix is the one that passed, the preview served from a host of its own. And the generous ad slot could
navigate the shop’s page at any moment and open pop-ups outside the sandbox, while the tight one could navigate it
only after a click. Add referrerpolicy to send less of your address, and loading="lazy" so that a frame far down
the page loads only when it comes near.
Sandboxing is the embedding side of the same rule: a frame should have the powers its job needs and nothing more.
Checkpoint
Check: Sandboxing the frames you embed
Frame-busting scripts and SameSite are extras, not the defence
Two older answers still turn up in code and in advice, and both help less than they seem to:
| Extra | When it helps | Why it is not enough |
|---|---|---|
| A frame-busting script that tries to escape frames | In very old browsers without either header | The framing page can switch scripts off in the frame with sandbox, and then the script never runs; OWASP lists other public ways around it |
| SameSite cookies | The action needs a login, and the frame is on another site | Same-site pages, such as a sibling subdomain, still get the cookie; an action without login is not covered; browsers differ in their defaults |
| A confirmation dialog | As one more step for the user | Some sandboxed frames suppress dialogs, and the call then returns false without asking |
MDN calls SameSite an additional, partial mitigation, and OWASP says it should not be the only protection. OWASP also describes a newer variant across separate windows, where no frame is involved and framing headers cannot help, and asks for additional safeguards on sensitive actions there. For the framed kind the core stays the same: the response header decides, and the test is an assertion on that header.
Test it as you test the baseline: request each kind of page on your own environments and check frame-ancestors,
and X-Frame-Options where a page uses it, on every response, error pages included. ZAP’s passive rule 10020 flags a
missing header, multiple X-Frame-Options entries, one set in a meta element and a malformed value, and the
module on security testing runs it in CI. Frame only pages you own or have written permission to test.
Key idea
Frame-busting scripts, SameSite cookies and dialogs are extras: the defence is a frame-ancestors header on every
response, checked by a test.
Checkpoint
Check: Why frame busting and SameSite are extras
Practise: framing headers for six pages
Write the framing headers of six kinds of page, from an OAuth consent screen to a widget for partners. The sample
tests judge each answer with the rules of this lesson, once as a current browser does and once as an old browser
that knows only X-Frame-Options:
Exercise · Medium · JavaScript, Python
Framing headers for six kinds of page
Write framingHeaders(page) in framing.mjs (Python: framing_headers(page) in framing.py). It returns the framing headers of one kind of page as an object (a dict in Python), header name to value. The sample tests check each answer with the browser rules of the lesson, in a current browser and in an old one that knows only X-Frame-Options:
- **
'consent','download'and'error'**: no page may frame them, not even your own. Sendframe-ancestorsand anX-Frame-Optionsthat old browsers follow too. - **
'app'**, the signed-in app athttps://app.example.com: only its own origin may frame it, in old browsers too. - **
'widget'** athttps://widget.example.com: only the origins inPARTNERSmay frame it. Send noX-Frame-Options, which cannot name them and would make old browsers refuse every partner. - **
'marketing'** athttps://www.example.com: its own origin,https://example.comand everyhttpssubdomain ofexample.commay frame it, and noX-Frame-Options.
The Content-Security-Policy holds only the frame-ancestors directive. Any other kind of page is an error: throw (Python: raise ValueError).
JavaScript · Starter code · framing.mjs
/** The origins that may embed the widget. */
export const PARTNERS = ['https://partner-a.example', 'https://partner-b.example'];
/**
* The framing headers of one kind of page: 'consent', 'download', 'error', 'app', 'widget' or 'marketing', as an
* object of header name to value. Any other kind throws.
*/
export function framingHeaders(page) {
// Starter: the same legacy header for every page.
return { 'X-Frame-Options': 'SAMEORIGIN' };
} The sample tests · framing.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { framingHeaders } from './framing.mjs';
const PAGES = { consent: 'https://login.example.com', download: 'https://app.example.com', error: 'https://app.example.com', app: 'https://app.example.com', widget: 'https://widget.example.com', marketing: 'https://www.example.com' };
// The lesson's rules: frame-ancestors checks every ancestor; X-Frame-Options counts only without it (old browsers:
// only X-Frame-Options). Ancestors are listed from the parent up to the top.
function matches(source, ancestor, self) {
if (source === "'self'") return ancestor === self;
if (/^[a-z]+:$/.test(source)) return ancestor.startsWith(`${source}//`);
const [scheme, host] = source.split('://');
const [aScheme, aHost] = ancestor.split('://');
if (scheme !== aScheme) return false;
return host.startsWith('*.') ? aHost.endsWith(host.slice(1)) : aHost === host;
}
function xfoAllows(xfo, self, ancestors) {
if (!xfo) return true;
const v = xfo.trim().toLowerCase();
if (v === 'deny') return false;
if (v === 'sameorigin') return ancestors.every((a) => a === self);
return true;
}
function allows(page, ancestors, { old = false } = {}) {
const h = framingHeaders(page);
const self = PAGES[page];
const fa = /frame-ancestors([^;]*)/.exec(h['Content-Security-Policy'] ?? '');
if (fa && !old) {
const sources = fa[1].trim().split(/\s+/);
return sources[0] !== "'none'" && ancestors.every((a) => sources.some((s) => matches(s, a, self)));
}
return xfoAllows(h['X-Frame-Options'], self, ancestors);
}
const OTHER = 'https://other.example';
test('consent, download and error pages refuse every frame, in old browsers too', () => {
for (const page of ['consent', 'download', 'error']) {
assert.ok(!allows(page, [PAGES[page]]), `${page}: not even its own origin`);
assert.ok(!allows(page, [OTHER]), `${page}: not another site`);
assert.ok(!allows(page, [PAGES[page]], { old: true }), `${page}: an old browser refuses it too`);
}
});
test('the app may be framed by its own origin only, at every level', () => {
assert.ok(allows('app', ['https://app.example.com']));
assert.ok(!allows('app', ['https://www.example.com']), 'a sibling host is another origin');
assert.ok(!allows('app', ['https://app.example.com', OTHER]), 'every ancestor counts, the top included');
assert.ok(allows('app', ['https://app.example.com'], { old: true }) && !allows('app', [OTHER], { old: true }), 'old browsers keep the same rule');
});
test('the widget may be framed by the partners only, and sends no X-Frame-Options', () => {
assert.ok(allows('widget', ['https://partner-a.example']) && allows('widget', ['https://partner-b.example']));
assert.ok(!allows('widget', [OTHER]) && !allows('widget', ['https://partner-a.example', OTHER]));
assert.ok(!('X-Frame-Options' in framingHeaders('widget')), 'X-Frame-Options cannot name partners');
});
test('the marketing page allows example.com and its https subdomains, and nothing else', () => {
for (const a of ['https://www.example.com', 'https://example.com', 'https://blog.example.com']) assert.ok(allows('marketing', [a]), a);
for (const a of ['http://blog.example.com', 'https://example.com.other.example', OTHER]) assert.ok(!allows('marketing', [a]), a);
assert.ok(!('X-Frame-Options' in framingHeaders('marketing')));
assert.throws(() => framingHeaders('video'));
}); Python · Starter code · framing.py
# The origins that may embed the widget.
PARTNERS = ["https://partner-a.example", "https://partner-b.example"]
def framing_headers(page):
"""The framing headers of one kind of page: 'consent', 'download', 'error', 'app', 'widget' or 'marketing', as
a dict of header name to value. Any other kind raises ValueError."""
# Starter: the same legacy header for every page.
return {"X-Frame-Options": "SAMEORIGIN"} The sample tests · test_framing.py
import re
from framing import framing_headers
PAGES = {
"consent": "https://login.example.com", "download": "https://app.example.com", "error": "https://app.example.com",
"app": "https://app.example.com", "widget": "https://widget.example.com", "marketing": "https://www.example.com",
}
OTHER = "https://other.example"
def matches(source, ancestor, self):
if source == "'self'":
return ancestor == self
if re.fullmatch(r"[a-z]+:", source):
return ancestor.startswith(source + "//")
scheme, host = source.split("://")
a_scheme, a_host = ancestor.split("://")
if scheme != a_scheme:
return False
return a_host.endswith(host[1:]) if host.startswith("*.") else a_host == host
def xfo_allows(xfo, self, ancestors):
if not xfo:
return True
value = xfo.strip().lower()
if value == "deny":
return False
if value == "sameorigin":
return all(a == self for a in ancestors)
return True
def allows(page, ancestors, old=False):
"""The lesson's rules: frame-ancestors checks every ancestor, X-Frame-Options counts only without it (old
browsers: only X-Frame-Options). Ancestors are listed from the parent up to the top."""
headers = framing_headers(page)
self = PAGES[page]
fa = re.search(r"frame-ancestors([^;]*)", headers.get("Content-Security-Policy", ""))
if fa and not old:
sources = fa.group(1).split()
return sources[0] != "'none'" and all(any(matches(s, a, self) for s in sources) for a in ancestors)
return xfo_allows(headers.get("X-Frame-Options"), self, ancestors)
def test_consent_download_and_error_pages_refuse_every_frame_in_old_browsers_too():
for page in ("consent", "download", "error"):
assert not allows(page, [PAGES[page]]), f"{page}: not even its own origin"
assert not allows(page, [OTHER]), f"{page}: not another site"
assert not allows(page, [PAGES[page]], old=True), f"{page}: an old browser refuses it too"
def test_the_app_may_be_framed_by_its_own_origin_only_at_every_level():
assert allows("app", ["https://app.example.com"])
assert not allows("app", ["https://www.example.com"]), "a sibling host is another origin"
assert not allows("app", ["https://app.example.com", OTHER]), "every ancestor counts, the top included"
assert allows("app", ["https://app.example.com"], old=True) and not allows("app", [OTHER], old=True)
def test_the_widget_may_be_framed_by_the_partners_only_and_sends_no_x_frame_options():
assert allows("widget", ["https://partner-a.example"]) and allows("widget", ["https://partner-b.example"])
assert not allows("widget", [OTHER]) and not allows("widget", ["https://partner-a.example", OTHER])
assert "X-Frame-Options" not in framing_headers("widget"), "X-Frame-Options cannot name partners"
def test_the_marketing_page_allows_example_com_and_its_https_subdomains_and_nothing_else():
for a in ("https://www.example.com", "https://example.com", "https://blog.example.com"):
assert allows("marketing", [a]), a
for a in ("http://blog.example.com", "https://example.com.other.example", OTHER):
assert not allows("marketing", [a]), a
assert "X-Frame-Options" not in framing_headers("marketing")
try:
framing_headers("video")
except ValueError:
return
raise AssertionError("an unknown kind of page must raise ValueError") A hint
Four answers cover the six pages: 'none' with DENY, 'self' with SAMEORIGIN, the partners' origins, and a list for the marketing page. For that list, remember that https://*.example.com matches the subdomains of example.com but not example.com itself, so the bare host needs an entry of its own.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the self-check. It asks one question at a time and begins with the idea you are least sure of:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is clickjacking, and what is the main defence?
Clickjacking is showing a page of your site inside a frame on another site, where the user does not notice it, lined
up under something harmless, so that a real click or keystroke acts on your page: a payment, a deleted account, a
granted permission. It needs only that your page may be framed, so the main defence is a response header that says
who may frame it: Content-Security-Policy: frame-ancestors 'none', or 'self', or a list of named origins, with
X-Frame-Options: DENY or SAMEORIGIN as a fallback for old browsers. It must be a header, not a meta element,
and it must be on every response that renders a page. Frame-busting scripts and SameSite cookies help at the edges,
but neither replaces the header.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
A partner wants to embed your payment confirmation page in a frame inside their own checkout. How do you decide, and if you agree, what do you configure and what do you change in the page?
-
Fresher to senior
Your site sends X-Frame-Options ALLOW-FROM https://partner.example, and its pages carry frame-ancestors in a meta tag. In a test of your own site, the account page loads inside a frame on another origin. Explain what the browser did with each rule, and the fix.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is clickjacking, and what does it need from your site?
Another site shows your page in a frame, lined up under something harmless, so the user's genuine click acts on your page (CWE-1021). It needs only that your page may be framed. The server never sees that the click came through someone else's page, so the defence belongs in the response.
-
How does the browser read frame-ancestors and X-Frame-Options together?
An enforced frame-ancestors header checks every ancestor, parent up to the top, and overrides X-Frame-Options. It is ignored in a meta element, does not fall back to default-src, and a report-only policy decides nothing. X-Frame-Options has only DENY and SAMEORIGIN; a lone unknown value such as ALLOW-FROM counts as no header, so the page fails open.
-
Which pages should refuse framing, and what is the default?
Every page where a click or a few keystrokes does something that matters: paying, deleting, changing an email or password, an OAuth consent screen, signing in. Make "no page may be framed" the default, with frame-ancestors on every response (ASVS 5.0.0 3.4.6), and configure the few embeddable pages as exceptions.
-
How do you let named partners embed a page safely?
Serve the embeddable part from an origin of its own, keep frame-ancestors 'none' for the rest of the app, list partners by origin, and keep what the embedded page can do small. https://*.example.com does not match example.com itself, a bare scheme such as https: or * allows the whole web, and X-Frame-Options cannot list several origins.
-
How does the sandbox attribute protect a page that embeds someone else's frame?
With no tokens it applies every restriction: no scripts, forms, pop-ups or navigation of your page, and an origin of its own. Each token gives one power back, so grant the fewest the content needs. allow-scripts together with allow-same-origin lets the framed document remove its own sandbox, so never use that pair for content served from your own origin.
-
Why are frame-busting scripts, SameSite cookies and confirmation dialogs not the defence?
A framing page can switch scripts off in the frame with sandbox, so the buster never runs. SameSite does not cover same-site pages such as a sibling subdomain, or actions that need no login (MDN calls it a partial mitigation). Some sandboxed frames suppress dialogs. The defence is the header on every response, asserted by a test.
Key takeaways
- Clickjacking needs only that another site may frame your page, so every page with an action that matters refuses framing, and embedding is a configured exception.
frame-ancestorsin an enforced header checks every ancestor and overridesX-Frame-Options; in ametaelement, in a report-only policy or left todefault-srcit protects nothing.X-Frame-Optionsknows onlyDENYandSAMEORIGIN;ALLOW-FROMis ignored and fails open, and two different values, one of themDENYorSAMEORIGIN, make the browser refuse.- Pages that must be embedded live on an origin of their own and name each partner; wildcards need the bare host
added, and a scheme or
*allows the whole web. - Frames you embed get the fewest sandbox tokens they need, never
allow-scriptswithallow-same-originfor content on your own origin. - Frame-busting scripts, SameSite cookies and dialogs are extras; the defence is the header, asserted by a test on every kind of response.
Terms in this lesson
- clickjacking
- Showing a page of one site inside a frame on another site, where the user does not notice it, lined up under something harmless, so that a real click or keystroke acts on the framed page. Also called UI redress (CWE-1021).
- frame ancestor
- A document that contains a frame, directly or further up: the parent, its parent and so on to the top-level page. frame-ancestors and X-Frame-Options SAMEORIGIN check every one of them, not only the parent.
- iframe sandbox
- The sandbox attribute of an iframe. With no tokens it takes away scripts, forms, pop-ups, navigation of the top page and the frame's own origin; each token, such as allow-scripts, gives one power back.
References
- A06:2025 Insecure Design (CWE-1021) (OWASP Foundation)
- Clickjacking Defense Cheat Sheet (OWASP Foundation)
- Clickjacking (MDN Web Docs (Mozilla))
- CSP: frame-ancestors (MDN Web Docs (Mozilla))
- X-Frame-Options header (MDN Web Docs (Mozilla))
- Content Security Policy Level 3: frame-ancestors (W3C)
- HTML Standard: the X-Frame-Options header (WHATWG)
- The iframe element: sandbox, allow, referrerpolicy and loading (MDN Web Docs (Mozilla))
- Application Security Verification Standard 5.0.0: V3 Web Frontend Security (OWASP Foundation)
- MDN browser compatibility data: Content-Security-Policy (MDN Web Docs (Mozilla))
- ZAP alert 10020: Anti-clickjacking Header (ZAP (Software Security Project))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress