How to check X-Frame-Options
Read the header and the CSP directive that replaced it in one request: curl -sI https://www.cloudflare.com/ | tr ';' '\n' | grep -i -E 'x-frame-options|frame-ancestors'. The value is DENY, SAMEORIGIN or absent. Where a frame-ancestors directive is also present, the browser applies that one and ignores this header, so presence alone is not the verdict.
Why check this
X-Frame-Options is the older of two framing controls and the one most configuration templates still set. Check it whenever a page starts being embedded on purpose, in a partner portal or an in-product help panel, because the fix people reach for is ALLOW-FROM, which no current browser implements. Step 6 shows the result: the header is dropped, the page frames from anywhere, and the console says so once.
Prerequisites
- curl 8 or later. See the curl manual.
- The MDN reference on X-Frame-Options for the two values it defines.
- Node 22 and Chrome for steps 5 and 6. Save the two scripts below, run each with
node, then stop both when the check is done.
const http = require('node:http');
const routes = {
'/open': {},
'/deny': { 'x-frame-options': 'DENY' },
'/sameorigin': { 'x-frame-options': 'SAMEORIGIN' },
'/allow-from': { 'x-frame-options': 'ALLOW-FROM http://localhost:8099' },
'/both': { 'x-frame-options': 'DENY', 'content-security-policy': "frame-ancestors 'self' http://localhost:8099" },
};
http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...routes[req.url] });
res.end('<!doctype html><meta charset="utf-8"><title>target</title><h2>target page</h2>');
}).listen(8100, () => console.log('target on http://localhost:8100/'));
const http = require('node:http');
http.createServer((req, res) => {
const target = new URL(req.url, 'http://localhost:8099').searchParams.get('u');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(`<!doctype html><meta charset="utf-8"><title>frame test</title>
<h1>Framing ${target}</h1>
<iframe id="f" src="${target}" width="620" height="320"></iframe>
<script>document.getElementById('f').onload = () => console.log('iframe load event fired');</script>`);
}).listen(8099, () => console.log('harness on http://localhost:8099/?u=URL'));
Steps
- Step 1.
Read the header on the route you are signing off.
curl -sI https://api.github.com/ | tr -d '\r' | grep -i '^x-frame-options:'x-frame-options: denyThe value is case insensitive.
denyandDENYmean the same thing. - Step 2.
Read both framing controls together, because one of them overrules the other.
curl -sI https://www.cloudflare.com/ | tr -d '\r' | grep -i -E '^x-frame-options:|^content-security-policy:' | tr ';' '\n' | sed 's/^ //' | grep -i -E '^x-frame-options|^frame-ancestors'frame-ancestors 'none' x-frame-options: SAMEORIGINThese two disagree.
SAMEORIGINpermits framing by the same origin,frame-ancestors 'none'permits framing by nobody. The CSP directive is the one that applies. - Step 3.
Count both controls on a host that sets neither, so an absent header is unambiguous.
curl -sI https://example.com/ | tr -d '\r' | grep -ic -E '^x-frame-options:|^content-security-policy:'0 - Step 4.
Start the local target and read the route that sets both headers with opposite meanings.
curl -sI http://localhost:8100/both | tr -d '\r' | grep -i -E '^x-frame-options:|^content-security-policy:'x-frame-options: DENY content-security-policy: frame-ancestors 'self' http://localhost:8099 - Step 5.
Start the harness, open
http://localhost:8099/?u=http://localhost:8100/bothin Chrome, and look at the frame.http://localhost:8099/?u=http://localhost:8100/bothiframe load event firedThe frame shows
target page. The header saidDENYand the page was framed anyway, becauseframe-ancestorslisted the parent origin. Chrome logged no refusal. - Step 6.
Open the route that tries to allow one specific parent with the old syntax.
http://localhost:8099/?u=http://localhost:8100/allow-fromInvalid 'X-Frame-Options' header encountered when loading 'http://localhost:8100/': 'ALLOW-FROM http://localhost:8099' is not a recognized directive. The header will be ignored. iframe load event firedThe frame renders. A header meant to restrict framing to one origin left the page framable by every origin.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| x-frame-options: DENY | No page may frame this one | Correct for anything with a session. Confirm no product feature needs an embed. |
| x-frame-options: SAMEORIGIN | Only pages on the same origin may frame it | Check that the app does not embed itself from a second hostname, which is a different origin. |
| x-frame-options: ALLOW-FROM … | Ignored by every current browser | Replace with frame-ancestors in CSP. Until then the page has no framing protection. |
| Header absent, frame-ancestors present | CSP is doing the work | Read the directive. The header is not needed for browsers in support today. |
| Both present and disagreeing | CSP wins, the header is ignored | Align the two so a reader of the config is not misled, then retest. |
| Neither present | Any site may frame the page | Decide the framing rule for the route and set frame-ancestors. |
Common mistakes
What to check next
- How to check if a site is vulnerable to clickjacking: the framing outcome test, which is what this header exists to control.
- How to check CSP header: where
frame-ancestorslives and how to read the rest of the policy. - How to check security headers: the sweep that finds the routes where this header is missing.
- How to check SameSite cookie attribute: whether a framed page would carry the session cookie at all.
FAQ
How to check the X-Frame-Options header?
Step 1 reads it with one HEAD request. Step 2 is the version to use in practice, because it also reads the CSP directive that takes precedence over it.
Is SAMEORIGIN enough?
It stops cross-origin framing, which is the clickjacking case. It does not stop a page on the same origin, so an application that hosts user content on its own origin needs DENY on the routes that carry actions.
Should I keep the header once CSP is set?
Keeping both costs nothing and covers browsers without frame-ancestors support. Keep the two values aligned. Step 2 shows a live pair that does not agree, which makes the configuration hard to audit.
Does a meta tag work instead of the header?
No. X-Frame-Options is honoured only as a response header, and CSP delivered by a meta tag ignores frame-ancestors. Framing rules have to come from the response headers.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny