Blog
3 min read

What Is Clickjacking? And the One Header That Stops It

Clickjacking tricks a user into clicking something on your site while they think they're clicking something else, by loading your page invisibly inside an attacker's page. How it works, what it can do, and the fix: CSP frame-ancestors and X-Frame-Options — with examples for Nginx, Next.js and Express.

Clickjacking is an attack where someone tricks your users into clicking a button on your site without realising it.

How it works

  1. The attacker makes a web page — a game, a "claim your prize" page, anything enticing.
  2. On it, they load your site inside an <iframe> and make it invisible (opacity: 0), positioned exactly over their own button.
  3. The victim, already logged in to your site, clicks what looks like "Play" on the attacker's page.
  4. The click actually lands on your invisible page — on "Delete account", "Make public", "Confirm transfer" or "Allow access".

Because the user is logged in, your site sees a genuine click from a genuine session.

What it can do

Anything a single click (or a few) can do on your site:

  • change settings (make a profile public, disable 2FA)
  • approve OAuth or permission requests
  • like, follow or share
  • confirm a purchase or transfer where there's no extra confirmation step

The fix: don't let other sites frame you

Tell browsers whether your pages may be shown inside frames on other sites. There are two headers.

Content-Security-Policy: frame-ancestors (modern)

Content-Security-Policy: frame-ancestors 'self'
  • 'none' — no one may frame your pages.
  • 'self' — only your own site may.
  • 'self' https://partner.example.com — your site and a specific partner.

(Content Security Policy guide)

X-Frame-Options (older, still useful)

X-Frame-Options: DENY

or SAMEORIGIN. Older browsers understand it; modern browsers prefer frame-ancestors. Sending both is common and harmless.

Setting it

Nginx:

add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;

Express, with Helmet (which sets these by default):

import helmet from 'helmet'
app.use(helmet())

Next.js (next.config.js):

async headers() {
  return [{
    source: '/(.*)',
    headers: [
      { key: 'Content-Security-Policy', value: "frame-ancestors 'self'" },
      { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
    ],
  }]
}

Caddy:

header {
    Content-Security-Policy "frame-ancestors 'self'"
    X-Frame-Options "SAMEORIGIN"
}

If you already have a CSP header, add frame-ancestors to it rather than sending a second one.

Check it

Open DevTools → Network → your page → Response Headers. Or use an online security-headers checker. (HTTP security headers explained)

A quick manual test: make a local HTML file with <iframe src="https://yoursite.com"></iframe>. If the headers work, the browser refuses to show it.

When you do need framing

If your product is meant to be embedded — a widget, a booking form, a chat box on customers' sites:

  • Allow framing only on the specific pages meant to be embedded, not your whole app (especially not account settings).
  • List the allowed parent domains in frame-ancestors if you know them.
  • Require an extra confirmation step for anything sensitive inside the embed.

Also helps

  • SameSite cookies — SameSite=Lax (the browser default) or Strict means your session cookie isn't sent when your site is framed by another site, so the framed page isn't logged in. (CSRF and SameSite)
  • Confirmation for destructive actions — retyping a name, re-entering a password.

EasySpawn serves your app behind a reverse proxy with sensible security headers already in place, including protection against being framed. See how it works or join the waitlist.

Related: HTTP Security Headers Explained · Content Security Policy · CSRF Explained · The OWASP Top 10 Explained

Keep reading