Skip to content
Back to field notes
BrowserJune 6, 20266 min read

Clickjacking risk: X-Frame-Options vs CSP frame-ancestors

An AI-built app that ships without a frame control can be loaded inside an attacker's invisible iframe and clicked through by your own logged-in users. This note shows how to choose and set the right header before launch.

BrowserHeadersClickjacking

A framed page is a clickable page someone else controls

The concrete launch risk is this: if your app sends no frame control, any other site can load your pages inside an iframe. An attacker overlays your iframe with their own page, makes your real interface transparent, and lines up a tempting button under one of your actual controls. Your logged-in user thinks they are clicking the attacker's page, but the click lands on a control inside your app. This is clickjacking, the UI redress class in the OWASP and CWE taxonomy, and the deciding factor is X-Frame-Options vs CSP frame-ancestors.

What is observable here is narrow and worth separating from what is only possible. Observable: whether your responses carry a frame-blocking header, and whether a test page can frame your app. That you can check directly. Only possible: that a specific account-state-changing action gets clicked through. A missing header does not prove anyone has been clickjacked. It proves the precondition that makes it feasible, and that is enough to fix before the URL spreads.

The actions that hurt are the ones a single framed click can trigger: confirm a payment, accept an invite, change a sharing setting, delete something, approve a permission. If a control does its work on one click while the user holds a valid session, framing turns that control into something an outside site can aim at.

Why AI-built apps ship without a frame control

A frame control is a response header, not a feature. Nothing in the running app looks wrong when it is absent, so it is exactly the kind of thing a generated build skips. The page renders, the buttons work, the demo is convincing, and no test fails because no header is missing from the user's point of view.

AI scaffolding optimizes for an app that works on the first load. Sending X-Frame-Options or a Content-Security-Policy with frame-ancestors is a deploy-time and platform-config concern, and that config is usually empty unless someone wrote it on purpose. The default for most static hosts and starter templates is to send no frame header at all, which means framing is allowed.

  • The header is invisible from inside the app, so the gap survives every manual click-through of the UI.
  • Frame-busting JavaScript is the tempting shortcut, and it is not enough. Top-frame-detection scripts can be neutralized by the sandbox attribute on the framing iframe, and they fail entirely if the user has scripts blocked or the script loads late. A header enforced by the browser does not have these holes.
  • Fast deploy means the public URL is live before anyone reviews response headers. This is one item on the broader header surface covered in security headers for AI-built apps.

Check whether your own app can be framed

You can confirm this on your own app with no attack on anyone. Two reproducible checks.

Read the response headers. Open DevTools, go to the Network tab, reload, click your main document request, and look at Response Headers. You are looking for either X-Frame-Options, or a Content-Security-Policy that contains a frame-ancestors directive. You can also pull headers from the command line with a request like curl -I https://your-app.example. Check more than the home page: the login page, the dashboard, and any page that holds a one-click action, because headers can differ per route.

Try to frame it. Make a local HTML file containing one iframe whose src is your app URL, and open that file in your browser. If your app appears inside the frame, it is frameable. If the browser refuses to render it and the console shows a refused-to-display or frame-ancestors message, your header is doing its job. Do this only against your own app.

  • Suspicious result: neither X-Frame-Options nor a CSP frame-ancestors directive is present on a page with real actions. Framing is allowed.
  • Safer result: you see X-Frame-Options: DENY or SAMEORIGIN, or a CSP with frame-ancestors set to none or a specific allowlist.

Choose DENY, SAMEORIGIN, or an allowlist, then set it once

Two headers do this job, and modern browsers treat them differently. CSP frame-ancestors is the current standard. When a browser sees frame-ancestors in a Content-Security-Policy, it uses that and ignores X-Frame-Options for framing decisions. X-Frame-Options still matters for older clients and as a clear fallback, but it cannot express an allowlist of multiple origins, which frame-ancestors can. Send both if you want the widest coverage, and make sure they agree.

Pick based on whether your app is ever legitimately embedded:

The exact place to set it depends on your platform. On Vercel and Netlify, add the header in the platform config (vercel.json headers, or a _headers file or netlify.toml on Netlify). On Nginx, use add_header for the relevant location. In a framework, set it where you already set response headers: Nuxt via routeRules headers or a server middleware, Next.js via the headers function in config, Express via a header-setting middleware. Set it as close to the platform edge as you can so every route is covered, not just the ones rendered by your app code.

What this does not cover: a frame control stops your pages from being framed by other origins. It does nothing about cross-site request forgery, where a request is forged without framing at all, and it does nothing about an action that should not be one click in the first place. Sensitive actions still need their own confirmation step and CSRF protection. Frame-ancestors is one control, not the whole defense.

  • Your app is never meant to be framed: send Content-Security-Policy: frame-ancestors 'none', and as a fallback X-Frame-Options: DENY. This is the right default for nearly every standalone app, dashboard, and SaaS login.
  • Your app frames only its own pages: use frame-ancestors 'self', with X-Frame-Options: SAMEORIGIN as the fallback.
  • Your app is embedded by named partners on purpose: use frame-ancestors with an explicit list of those origins, for example 'self' https://partner.example. There is no X-Frame-Options equivalent for a multi-origin allowlist, so frame-ancestors is the one that carries the rule. Never widen this to a permissive wildcard.

Authentication is not the issue here, and neither is authorization. The framed user is correctly logged in and genuinely allowed to perform the action. Clickjacking abuses the user interface to make them perform it without intent. A frame control is the right fix for that specific gap; it does not replace session security or per-action confirmation.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check reads your public response headers and looks at both X-Frame-Options and the CSP frame-ancestors directive. A page that sends neither, or that sends a permissive frame control where a stricter one is warranted, is flagged directly with severity and fix guidance. It checks the header an outside browser actually receives, which is the same thing a would-be framer would see.

What it does not do is decide your policy for you. Whether your app should be DENY, SAMEORIGIN, or an allowlist of named partners is a product decision that depends on who is supposed to embed you, and that lives in your config and your judgment. The scan reads the public surface; it does not review your CSRF tokens, your per-action confirmations, or your auth flow, all of which need code-level and manual review.

Next step: pick DENY for a standalone app or an explicit frame-ancestors allowlist if you genuinely embed, set it at the platform edge so every route inherits it, then reframe your own app from a local test file to confirm the browser refuses. Run a focused Launch Check before sharing the URL publicly, so a missing frame control shows up in your scan and not in an attacker's.

A clean header check does not prove your app cannot be abused. It confirms the frame control is present and not obviously permissive on the pages scanned. It does not confirm that every route sends it, that your CSP is otherwise sound, or that sensitive actions have their own guardrails. Treat a missing frame header as a launch blocker, and a present one as a single passing signal.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.