Clickjacking prevention is essential for modern web apps. Learn how to implement Content Security Policy and X-Frame-Options to stop UI redressing attacks.
During an on-call rotation last year, I spent an entire afternoon debugging why our support dashboard was suddenly triggering weird UI behavior for our users. It turned out to be a classic case of Clickjacking, where a malicious site had loaded our admin panel inside an invisible iframe, tricking our staff into performing actions they didn't intend to. We fixed it quickly, but it was a sobering reminder of how easy it is to overlook UI redressing vulnerabilities.
At its core, Clickjacking is a visual deception. An attacker loads your legitimate website in an iframe on a site they control. They then overlay that iframe with transparent elements, making the user think they’re clicking a "Win a Free iPad" button when they’re actually clicking "Delete Account" on your site.
If you aren't explicitly telling browsers who is allowed to frame your content, they assume the answer is "everyone." That’s a dangerous default in today's landscape. Just like we have to be careful with Preventing DOM-based XSS or Preventing Improper CORS Policy Configuration, framing controls are a non-negotiable part of your security headers.
Before modern policies, we relied on the X-Frame-Options (XFO) header. It’s simple, widely supported, and essentially acts as a binary switch for your site's framing capabilities.
You generally have two options:
DENY: No one can frame your site.SAMEORIGIN: Only pages on the same origin as the framed page can frame it.We used SAMEORIGIN for about two years across our main applications. It works, but it’s inflexible. If you ever need to allow a specific partner site or a subdomain to frame your content, X-Frame-Options falls apart. It’s a blunt instrument in a world that often requires surgical precision.
This is where the frame-ancestors directive in your Content Security Policy (CSP) comes in. It’s the successor to XFO and offers far more granular control. Instead of a simple "yes" or "no," you can define exactly which domains are authorized to embed your application.
To implement this, you’ll send a Content-Security-Policy header from your server. Here is how I typically configure it for a standard production app:
HTTPContent-Security-Policy: frame-ancestors 'self' https://trusted-partner.com;
In this example, 'self' allows your own origin to frame the page, and https://trusted-partner.com allows that specific domain to embed your app. If you want to block all framing entirely, you would use:
HTTPContent-Security-Policy: frame-ancestors 'none';
You might be wondering: "If CSP is the modern standard, can I drop X-Frame-Options?"
Technically, yes, if you only support modern browsers. However, I still recommend sending both headers. It’s a "defense in depth" strategy. If a user happens to be on a legacy browser that doesn't understand CSP, it will ignore the frame-ancestors directive but still respect the X-Frame-Options header.
The configuration looks like this in a typical Express.js middleware:
JAVASCRIPTapp.use((req, res, next) => { res.setHeader(CE9178">'X-Frame-Options', CE9178">'SAMEORIGIN'); res.setHeader(CE9178">'Content-Security-Policy', "frame-ancestors CE9178">'self' https://trusted-partner.com"); next(); });
When we first rolled this out, we broke our own internal tool that required cross-domain framing for a legacy dashboard. We initially thought a blanket DENY was the right move, but we failed to account for our internal micro-frontends.
We had to pivot to a whitelist approach using frame-ancestors. It took about two days to map out every valid origin that actually needed to frame our content. It was tedious, but it was worth the effort to stop the potential for UI redressing attacks.
If I were starting this from scratch today, I’d start with frame-ancestors 'none' in my development environment. It forces you to be explicit about your requirements from day one. If something breaks, you know exactly why—you haven't authorized that origin yet.
Does this protect against all forms of UI redressing?
No. While frame-ancestors stops framing attacks, it doesn't protect against other forms of UI redressing that don't involve iframes, such as using CSS to manipulate the layout of your page if you allow user-generated content. Always sanitize your inputs.
What happens if I set both headers to different values?
Browsers are smart enough to handle this, but the behavior can vary. Generally, if the browser supports CSP, it will prioritize frame-ancestors and ignore X-Frame-Options. Always keep them consistent to avoid confusing your team during an incident.
Should I use 'unsafe-inline' in my CSP?
That's a different discussion regarding XSS, but keep in mind that your CSP is a single header. Don't mix up your frame-ancestors policy with your script-src policies. Keep your headers clean and modular.
Securing your frames is a low-effort, high-impact task. It takes about 20 minutes to implement and protects your users from one of the most insidious types of browser-based attacks. Don't wait for a production incident to add these headers to your stack.
Master rate limiting for API security. Learn to defend your Node.js and Laravel endpoints against brute-force attacks and resource exhaustion in production.
Read moreParameter pollution can lead to serious logic flaws in your web apps. Learn how to secure your Express and Laravel request parsing against manipulation.