Clickjacking is still alive in 2026, and AI-generated code is why
Von Seaworthy · 29. Juni 2026
Photo by Philipp Katzenberger on Unsplash
The attack is older than most junior developers
Clickjacking was documented in 2008. Eighteen years later, I still find it in production apps. Not obscure corner cases, but login pages and payment buttons on sites with real users.
The premise is embarrassingly simple. An attacker loads your page in an invisible <iframe>, then layers a fake UI on top. The user thinks they're clicking "Claim your prize." They're actually clicking your "Confirm payment" button. No exploit code. No zero-day. Just <iframe src="https://your-app.com"> and some CSS.
The fix is one line. That's what makes it maddening.
Why AI-generated apps keep getting this wrong
Here's my honest read on this: LLMs are trained on millions of code examples, and most of those examples never include security headers. Stack Overflow answers don't mention them. GitHub starter templates skip them. Tutorial blog posts, the kind that dominate training data, focus on getting the feature working, not hardening it.
So when a developer asks an AI assistant to "build me a Next.js app with authentication," they get a functional, plausible-looking result. The auth works. The routes are protected. The database queries are parameterized. But the HTTP response headers are whatever the framework ships by default, which is usually nothing useful.
I've checked dozens of AI-scaffolded projects. Nearly all of them are missing Content-Security-Policy: frame-ancestors 'none' or its older sibling X-Frame-Options: DENY. The developers didn't know to ask, and the model didn't think to include it.
What the vulnerable response looks like
A typical Express app generated by an AI assistant ships something like this:
// Generated server — no frame protection
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('<html><body><button>Confirm payment</button></body></html>');
});
app.listen(3000);
No headers. Your page can be embedded in any <iframe> on any origin, anywhere in the world. The attacker's page is trivial:
<!DOCTYPE html>
<html>
<head>
<style>
iframe {
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
}
.fake-button {
position: absolute;
top: 200px;
left: 100px;
z-index: 1;
}
</style>
</head>
<body>
<button class="fake-button">Click here to claim your reward</button>
<iframe src="https://your-app.com/confirm-payment"></iframe>
</body>
</html>
The user sees "Click here to claim your reward." They click it. They've just confirmed a payment.
The fix, and why you should use CSP instead of the old header
X-Frame-Options was the original defence. Still supported everywhere and worth setting. But it has no granularity: you can deny all framing or allow same-origin framing, nothing in between.
Content-Security-Policy: frame-ancestors is the modern replacement. It lets you allowlist specific origins if you legitimately need to embed your app somewhere, say in a partner's portal. It's also part of the broader CSP directive system, so it sits alongside script-src, img-src, and the rest of your policy.
Here's the same Express server with proper headers:
const express = require('express');
const app = express();
app.use((req, res, next) => {
// Refuse to be embedded in any iframe on any origin
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader(
'Content-Security-Policy',
"frame-ancestors 'none'"
);
next();
});
app.get('/', (req, res) => {
res.send('<html><body><button>Confirm payment</button></body></html>');
});
app.listen(3000);
If you use Next.js, add these to next.config.js inside the headers() export. If you use a CDN or reverse proxy, set them at that layer so they apply to every response, not just the routes you remember to cover.
One thing developers often miss: frame-ancestors 'none' is not the same as frame-ancestors 'self'. 'self' allows your own origin to embed the page. That sounds safe, but if any page on your domain is vulnerable to XSS, an attacker can host an iframe there too. 'none' is almost always the right choice for anything users interact with.
When you legitimately need framing
Some apps do need to be embeddable: payment widgets, OAuth flows inside partner portals, analytics dashboards. In those cases, allowlist exactly the origins that need access:
Content-Security-Policy: frame-ancestors https://trusted-partner.com
Not *. Never *. A wildcard here defeats the entire point. You may as well have no header.
The broader problem
Clickjacking is one of those vulnerabilities where the fix costs almost nothing and the consequence of skipping it can be a payment fraud incident or an account takeover. The barrier is awareness, not effort.
AI coding assistants have made it much faster to get a working app into production. That is genuinely useful. But they have also made it much faster to get an incomplete app into production, one that works correctly for the happy path and leaves security headers as an afterthought.
If you are building on AI-generated scaffolding, audit your response headers before you ship. It takes ten minutes and a browser DevTools panel. Check that frame-ancestors is present on every response that a user can interact with, not just your index route.
The gap between "the app works" and "the app is safe to ship" is exactly where most vibe-coded projects fall apart.
Seaworthy is a static analysis tool that scans repositories for missing security headers and other common gaps left by AI-assisted development workflows.
Dieser Artikel wurde von KI generiert und fasst öffentlich verfügbare Quellen zusammen.
Seaworthy scannt dein Repository nach den Problemen, die in Artikeln wie diesem behandelt werden.
Sicherheitslücken, offengelegte Geheimnisse und Fehlkonfigurationen — erkannt, bevor du bereitstellst. Kostenlos, kein Konto nötig.