Guides7 min read·

X-Frame-Options and CSP frame-ancestors, explained

What X-Frame-Options and CSP frame-ancestors do, which wins, why sites show BLOCKED in previews, and correct configs for Vercel, Netlify, Next.js and Express.

X-Frame-Options and the CSP frame-ancestors directive are HTTP response headers that tell browsers which websites may show your page inside a frame. X-Frame-Options only knows "nobody" (DENY) and "my own site" (SAMEORIGIN), while frame-ancestors can list exact sites, and when both are sent, current browsers follow frame-ancestors. They exist to stop clickjacking, and they're also why some sites show BLOCKED in preview tools.

What clickjacking is

Clickjacking is a trick where another website loads your page in an invisible frame and lines it up under something tempting. You think you're clicking "Play video" on their page. You're actually clicking "Delete account", "Send payment" or "Allow access" on yours, while logged in.

The defence is simple: tell the browser your page must not be shown inside other people's frames. That's what these two headers do (OWASP clickjacking defense cheat sheet).

The two headers

X-Frame-Options

The older header, with one value:

Value Meaning
DENY No site may frame this page, not even your own.
SAMEORIGIN Only pages from the same origin (same scheme, host and port) may frame it.
ALLOW-FROM https://… Obsolete. Current browsers ignore the whole header when they see it, so the page ends up with no protection.

That last row catches people out. If you copied ALLOW-FROM from an old answer to allow one partner site, you've switched protection off for everyone (MDN: X-Frame-Options).

CSP frame-ancestors

The modern replacement is a directive inside the Content-Security-Policy header:

Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self'
Content-Security-Policy: frame-ancestors 'self' https://partner.example https://*.example.org

'none' works like DENY, 'self' like SAMEORIGIN, and anything after that is a list of origins that may frame you, including wildcard subdomains. It checks every ancestor, not just the direct parent, so a page nested three frames deep is only shown if all three are allowed (MDN: frame-ancestors).

How they interact

X-Frame-Options CSP frame-ancestors
Nobody may frame DENY 'none'
Only my own site SAMEORIGIN 'self'
Specific other sites Not possible List them
Works in a <meta> tag No No
When both are sent Ignored Wins

The rules worth remembering:

  1. frame-ancestors wins. If a response has a CSP with a frame-ancestors directive, current browsers ignore X-Frame-Options. It's only there for browsers too old to understand CSP.
  2. Every CSP policy is enforced. Send two Content-Security-Policy headers and the page has to pass both, so the stricter one wins. If your framework already sends frame-ancestors 'self', adding a second header that allows another site won't help. Edit the existing policy.
  3. Headers only. frame-ancestors is ignored in <meta http-equiv="Content-Security-Policy">, and X-Frame-Options in a meta tag does nothing. You have to send them from the server.
  4. No header means anyone can frame you.

Why some sites show BLOCKED in preview tools

Tools that show your site on many screens at once, like doesitfit, put your page inside frames sized like each device: 402px wide for an iPhone 17 Pro, 360px for a Galaxy S25. If your server says "no frames" or "only my own site", the browser refuses to draw the page, and there's nothing the tool can do about it.

doesitfit checks your headers before loading the screens. When the answer is no, it shows BLOCKED with the reason, such as X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'self'. In your own DevTools console, the same refusal looks something like:

Refused to display 'https://www.example.com/' in a frame because it set 'X-Frame-Options' to 'sameorigin'.

Another reason you might see: a plain http:// page can't be shown inside an https:// site, so a live site that's still on http is blocked too.

Big sites like banks, email and social networks block framing on purpose, and they're right to. Your own site might be doing it by default without you knowing:

  • Helmet (Express) sends X-Frame-Options: SAMEORIGIN and a CSP with frame-ancestors 'self' by default.
  • Django sends X-Frame-Options: DENY by default.
  • Rails sends X-Frame-Options: SAMEORIGIN by default.
  • Hosting dashboards, CDNs and security plugins can add either header too.

If you'd rather not change headers at all, you have two options. Check your localhost or a staging copy, where you control the headers. Or ask your AI through the doesitfit MCP server (/connect): it opens your page directly in a browser at each screen size instead of inside a frame, so framing headers don't stop it. See check your website from Claude, ChatGPT or Codex.

How to allow one specific site

To keep clickjacking protection but let one site you trust frame your pages, send:

Content-Security-Policy: frame-ancestors 'self' https://doesitfit.lol
  • 'self' keeps your own site allowed.
  • The origin is exact: scheme and host, with https://.
  • If you already send a Content-Security-Policy header, add the frame-ancestors directive to it (separated by a semicolon) instead of sending a second header.
  • You can leave X-Frame-Options: SAMEORIGIN in place. Current browsers ignore it because frame-ancestors is present, and very old ones keep the stricter protection.

What you shouldn't do is open framing to everyone with frame-ancestors * or by deleting the headers. That brings clickjacking back for every page, including the ones with buttons that matter. If you only need previews, consider allowing them on staging or preview deployments only, or on public marketing pages but not on account, checkout or admin pages.

Config examples

Every example below sends the same header. Swap in your own list of allowed origins.

Vercel (vercel.json)

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "frame-ancestors 'self' https://doesitfit.lol"
        }
      ]
    }
  ]
}

On a Next.js project, you can also set it in next.config.js (below).

Netlify (_headers)

Create a file called _headers in your publish directory. In a Vite project, putting it in public/ gets it copied there on build:

/*
  Content-Security-Policy: frame-ancestors 'self' https://doesitfit.lol

Or in netlify.toml:

[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = "frame-ancestors 'self' https://doesitfit.lol"

Cloudflare Pages reads the same _headers format.

Next.js (next.config.js)

/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "frame-ancestors 'self' https://doesitfit.lol",
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

If your file is next.config.mjs or next.config.ts, use export default nextConfig; instead of module.exports. If your app already builds a CSP in middleware, add frame-ancestors there rather than sending a second policy.

Allow previews only (Next.js on Vercel)

On Vercel, the VERCEL_ENV environment variable is production, preview or development, so you can keep production locked down and allow framing everywhere else:

const allowed = process.env.VERCEL_ENV === 'production'
  ? "frame-ancestors 'self'"
  : "frame-ancestors 'self' https://doesitfit.lol";

/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [{ key: 'Content-Security-Policy', value: allowed }],
      },
    ];
  },
};

module.exports = nextConfig;

Then point doesitfit at your preview URL instead of the live site. The same idea works anywhere your build knows which environment it's in.

Express with Helmet

import express from 'express';
import helmet from 'helmet';

const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        frameAncestors: ["'self'", 'https://doesitfit.lol'],
      },
    },
  })
);

Helmet keeps its other default CSP directives and replaces only frame-ancestors. It still sends X-Frame-Options: SAMEORIGIN, which current browsers ignore because frame-ancestors is present.

Plain Express, no Helmet:

app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', "frame-ancestors 'self' https://doesitfit.lol");
  next();
});

Does your site fit?

Paste your address (or localhost:3000). doesitfit shows it on real screen sizes at once and tells you, bluntly, where it spills.

Free, no sign-up. Or ask Claude, ChatGPT or Codex to check it.

Test it with curl

After you deploy, check the headers the server actually sends:

curl -sI https://www.example.com/ | grep -iE '^(x-frame-options|content-security-policy)'

-I fetches only the headers. A few things to watch:

  • Redirects. If the address redirects (http to https, or bare domain to www), add -L to follow it. What counts is the header on the final page.
  • HEAD vs GET. -I sends a HEAD request, and a few servers answer those differently. To see the headers of a normal GET, use curl -s -D - -o /dev/null https://www.example.com/.
  • Every path. Headers can differ per route. Check the actual page you want to preview, not just the home page.
  • Duplicates. If you see two content-security-policy lines, both are enforced and the stricter one wins.

You can see the same thing in DevTools: Network tab, click the page request, then Response Headers. Once the header looks right, reload doesitfit and the BLOCKED card should be replaced by your site on every screen.

The short version

  • X-Frame-Options knows DENY and SAMEORIGIN. ALLOW-FROM is dead, and using it removes your protection.
  • Content-Security-Policy: frame-ancestors can list exact sites, and it wins when both are present.
  • Neither works in a <meta> tag. Send headers.
  • To allow one site: frame-ancestors 'self' https://that-site.example. Never *.
  • Test with curl -sI, and check for duplicate CSP headers.
  • Headers are one part of shipping safely; the vibe coding launch checklist has the rest. To preview your work in progress on a phone, see how to open localhost on your phone, and browse the screens worth checking in /devices.

Questions people ask

Does frame-ancestors override X-Frame-Options?

Yes. When a response has a CSP frame-ancestors directive, current browsers ignore X-Frame-Options. Very old browsers that don't understand CSP still use X-Frame-Options.

Is X-Frame-Options ALLOW-FROM still supported?

No. ALLOW-FROM is obsolete, and current browsers ignore a header that uses it, which leaves the page unprotected. Use Content-Security-Policy: frame-ancestors with the sites you want to allow instead.

Can I set frame-ancestors or X-Frame-Options in a meta tag?

No. frame-ancestors is ignored in a CSP meta tag, and X-Frame-Options in a meta tag has no effect either. Both must be sent as HTTP response headers.

How do I let only one website embed mine in an iframe?

Send the header Content-Security-Policy: frame-ancestors 'self' https://allowed-site.com. List each origin you want to allow, with its https:// scheme, and nothing else.

Why does my site show BLOCKED in doesitfit?

Your server sends X-Frame-Options or a CSP frame-ancestors rule that doesn't allow https://doesitfit.lol, so the browser refuses to show your page inside doesitfit's frames. Allow that origin, or check your localhost or staging copy instead.

Keep reading