What clickjacking actually is
Clickjacking is when an attacker loads your site inside an invisible iframe on their own page, then puts a tempting button right under where your real button sits. The user clicks the visible decoy. The browser registers a click on your hidden page.
A classic example. The attacker runs a “Win a free iPhone” page with a big “Claim Prize” button. On top of that button, with opacity: 0, they have loaded your bank’s transfer confirmation page or your Amazon checkout. The user clicks “Claim Prize” and actually clicks “Confirm Transfer”.
The trick works because the click really does happen on your legitimate page. The browser is not confused. The user is. There is no script injection, no XSS, just a transparent layer.
The two headers that fix it
You stop clickjacking by telling the browser: do not let anyone embed my page in an iframe unless I say so. Two headers do this.
X-Frame-Options
The older one. Simple, well supported, only two values matter in practice:
DENY: nobody can embed your page, not even you. This is the safe default.SAMEORIGIN: only pages from your own domain can embed it. Use this if your app has internal frames.
There is a third value, ALLOW-FROM, but it never had consistent browser support. Ignore it.
CSP frame-ancestors
The newer one, part of Content Security Policy. It does the same job as X-Frame-Options but lets you whitelist specific domains. That is the difference that matters: with XFO you cannot say “let partner.com embed me but nobody else”, you can only say “everyone or nobody”. With frame-ancestors you can.
If both headers are present, browsers follow frame-ancestors and ignore X-Frame-Options. That is in the CSP Level 2 spec.
Two variants worth knowing
Cursorjacking. The attacker hides the real cursor with cursor: none and draws a fake cursor a few pixels away. You think you are clicking the safe element you can see your fake cursor on. The real click lands somewhere else.
Drag-and-drop clickjacking. The attacker tricks you into dragging something out of an invisible iframe (a session token shown on a hidden page, an email address) into a field on their page. Same defense: stop the page from being embeddable in the first place.
Both variants die the moment your page refuses to load in an iframe.
A note on consent
If a user “accepts” cookies or new privacy terms because they were tricked into clicking a hidden button, that consent is not legally valid. So this is not just a security control, it is part of how you keep your consent mechanism clean under GDPR.
For a layered setup, also make sure your site enforces HTTPS with HSTS.
Setting the headers in Next.js
Set them globally in next.config.js:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none';" }
],
},
]
},
}
DENY plus frame-ancestors 'none' covers you for legacy browsers and modern ones at the same time.
When you actually need to allow embedding
If you sell an embeddable widget, a support chat, a video player, an analytics dashboard your customers paste into their site, you cannot use DENY. You need frame-ancestors with the specific domains that are allowed to embed you:
Content-Security-Policy: frame-ancestors 'self' https://client-domain.com https://other-client.com;
Keep that list under your control. Add a domain when a customer signs up, remove it when they leave. Do not use a wildcard.