X-Frame-Options: what the header allows, and what it does not tell you
X-Frame-Options is the HTTP response header that tells the browser whether a page may be displayed in a frame. DENY blocks all framing, SAMEORIGIN allows your own origin only, and ALLOW-FROM is ignored by modern browsers: present, it protects nothing. This page gives the command to read the final response after a redirect, the exact fix per stack, and what the control does not tell you.
1. The fact
X-Frame-Options is a response header. It tells the browser whether the page it just received may be displayed inside a frame: an iframe, an object or an embed, a window cut into another page. Three values: DENY (no frame, ever), SAMEORIGIN (your own origin only), ALLOW-FROM (one named site, obsolete, section 3). Without it, any site can display your page inside a frame.
2. Why it exists
A concrete case: your admin panel has a "Delete account" button at a fixed position. An attacker's page shows something harmless and loads your admin panel on top of it, in an invisible iframe: near-zero opacity, positioned so the button sits under the cursor. The click is a real click, in your page, with the visitor's session open.
The anti-CSRF token does not help here: it sits in the page the attacker frames, and it is sent as usual. Your server sees a legitimate request. The attacker needs no access to your server: only a page and a click in the right place.
The same mechanism works with a payment iframe (confirm an amount, a payee) or an admin panel (grant a permission, change an email address). The header appeared with Internet Explorer 8 to make that assembly impossible: the browser refuses an unauthorised frame before the click even exists.
3. What the value actually allows
DENY refuses all framing, including by your own site, and breaks internal iframes: preview panes, print views.
SAMEORIGIN allows pages from the same origin. An origin is not a domain: it is scheme, host and port, so https://app.example.com cannot frame https://www.example.com. It applies to every ancestor of a nested frame, not just the direct parent.
ALLOW-FROM is the central trap. MDN classes it as obsolete and states that modern browsers encountering it ignore the header completely. The result is not a weaker protection. It is no protection.
An ALLOW-FROM is more dangerous than no header at all, and the reason is human. An absence is obvious. An ALLOW-FROM looks like configured protection. It reassures whoever reviews the config file, and it is counted as "present" by every tool that checks for the header name. The page stays framable by anyone.
One last case: two values, when a CDN adds it on top of the origin. Mozilla documented in 2012 that a duplicated value caused the header to be ignored.
4. Check it yourself
curl -sI https://your-site.com | grep -i x-frame-optionsWhat to read: DENY or SAMEORIGIN. Nothing: three possible causes. The header is not configured. Or it is set on the 200 only, and the command above, which does not follow the redirect, reads the 301. Or the server answers HEAD badly and the test has to be re-run with GET. ALLOW-FROM: no protection, whatever site is named. SAMEORIGIN, SAMEORIGIN or two successive lines: duplicated value, fix it.
Apex to www: the command above does not follow redirects, and a 301 often carries no header. The final response is the one that counts.
curl -sIL https://your-site.com | grep -iE "^(HTTP/|location:|x-frame-options)"The output shows each hop; the header must appear in the last block, the 200. If it only appears in the first, your final page is not covered while the config looks like it is. If the result is empty while the header is configured, re-run with GET: some servers treat HEAD separately.
curl -s -D - -o /dev/null https://your-site.com | grep -i x-frame-optionsOne page says nothing about the others: check the login page and an authenticated route too.
5. Fixing it, per stack
Next.js (next.config.js):
// next.config.js
module.exports = {
async headers() {
return [{ source: '/:path*', headers: [{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }] }]
},
}If two rules set the same key, the last one wins, and this config cannot remove a header: a route you want framable has to fall outside the rule.
Vercel (vercel.json, project root):
{ "headers": [{ "source": "/(.*)", "headers": [{ "key": "X-Frame-Options", "value": "SAMEORIGIN" }] }] }Pick one place. If Vercel and the CDN both set it, section 4 shows how to spot the duplicate.
Nginx:
add_header X-Frame-Options SAMEORIGIN always;always also covers error codes (without always, 4xx and 5xx responses go out without the header). More importantly, higher-level add_header lines are inherited only if the current block defines none: adding one inside a location silently drops the server ones, HSTS included (add_header_inherit merge; since 1.29.3).
Apache (mod_headers):
Header always set X-Frame-Options "SAMEORIGIN"In the <VirtualHost> or a .htaccess with AllowOverride FileInfo. always also covers error responses.
Cloudflare: Rules > Transform Rules > Modify Response Header, operation Set static, Header name X-Frame-Options, Value SAMEORIGIN. Not "Add static": it does not overwrite an existing header of the same name and produces the duplicate from section 3.
WordPress: core already sends the header on the login page, not on the public site. Prefer the CDN or server rule; as a last resort, functions.php:
add_action( 'send_headers', function () { header( 'X-Frame-Options: SAMEORIGIN' ); } );It fails if output has already been sent, and a second plugin can add its own value. A <meta http-equiv="X-Frame-Options"> tag has no effect (MDN): if a theme contains one, it protects nothing.
Breakage risk: DENY breaks every legitimate iframe (payments, video players, external forms, a page of your own site), SAMEORIGIN breaks framing across subdomains. Set the value in staging, open every flow that loads an iframe, then deploy.
6. Where frame-ancestors fits
Content-Security-Policy: frame-ancestors 'none';
Content-Security-Policy: frame-ancestors 'self' https://app.example.com;'none' matches DENY, 'self' matches SAMEORIGIN, and the directive accepts a list of origins.
frame-ancestors does not fall back to default-src: a policy that declares default-src 'none' still allows framing. It checks every ancestor. And it does not exist in Internet Explorer 11 (CSP2 is not implemented there): that is the only reason to keep X-Frame-Options.
Which one wins in 2026: frame-ancestors. The CSP3 spec is explicit: if the policy contains the directive with an "enforce" disposition, the header is ignored. A Report-Only policy therefore overrides nothing. And if you relax frame-ancestors on an embed route without touching the global DENY, modern browsers obey the CSP while IE11 blocks: both must say the same thing.
In practice: send both, with the same intent ('none' with DENY, 'self' with SAMEORIGIN).
7. What this control does not tell you
X-Frame-Options does not protect against XSS: an injected script runs with your origin, framable or not. Two different controls.
A present header does not prove the page is sound: it may say ALLOW-FROM, be duplicated, exist only on the home page, or come from the CDN while the origin sends nothing.
A site with no form, no session and no state-changing action has almost nothing to protect: the attack needs an action worth triggering on the visitor's behalf. A read-only brochure site that can be framed is not made vulnerable by that; it has a configuration to complete, and that is a different problem.
A missing header means one thing: framing is allowed. It is not a demonstrated attack, and this page demonstrates no exploit.
One vendor study, not independently audited, counts the header deployed with a value its engine rates acceptable on 46.2% of the 3,494 home pages it could score out of the top 5,000 it crawled on 22 July 2026 (myssl.info). Its engine judges the value acceptable, not how effective the configuration really is. It counts the presence of the name, not the validity of the value.
Finally, it covers framing only: nothing on window.opener, popups, or the cross-site leaks that framing still allows.
8. One action
A single action: set X-Frame-Options: SAMEORIGIN on the final response of your domain, where headers terminate (CDN or server, not both), then check with curl -sIL that the 200 carries a single value. Only then move authenticated routes to DENY, after listing the iframes you legitimately embed.