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

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

  1. 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: deny

    The value is case insensitive. deny and DENY mean the same thing.

  2. 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: SAMEORIGIN

    These two disagree. SAMEORIGIN permits framing by the same origin, frame-ancestors 'none' permits framing by nobody. The CSP directive is the one that applies.

  3. 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
  4. 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
  5. Step 5.

    Start the harness, open http://localhost:8099/?u=http://localhost:8100/both in Chrome, and look at the frame.

    http://localhost:8099/?u=http://localhost:8100/both
    
    iframe load event fired

    The frame shows target page. The header said DENY and the page was framed anyway, because frame-ancestors listed the parent origin. Chrome logged no refusal.

  6. Step 6.

    Open the route that tries to allow one specific parent with the old syntax.

    http://localhost:8099/?u=http://localhost:8100/allow-from
    
    Invalid '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 fired

    The 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

Sign: The header reads DENY and the page still frames on a partner site.Cause: A CSP frame-ancestors directive on the same response lists that origin. Where both are sent, browsers apply frame-ancestors and ignore X-Frame-Options, which step 5 reproduces against a local target.
Sign: ALLOW-FROM was added to permit one partner and the page became framable everywhere.Cause: ALLOW-FROM was never implemented by Chrome or Safari and was removed from Firefox. An unrecognised value makes the whole header invalid, so the browser drops it and applies no restriction at all.
Sign: A test asserts that the iframe failed to load, and it passes on a page that framed fine.Cause: The iframe load event fires either way. Chrome fires it for the blocked placeholder as well as for the real document, so the event is not a framing verdict. Read the console message or look at the frame.
Sign: The header is present on the page and absent on the response that redirects to it.Cause: Proxy rules are often attached to the application response, not to the redirect. The intermediate response is a document the browser can frame, so check it with curl -D - rather than following the redirect.

What to check next

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.

intermediate8 minpublished updated Maks Verny