Requests, files, and servers
Stop clickjacking
Control which sites may frame a page and add confirmation for sensitive actions that should never be triggered through a disguised interface.
Clickjacking loads your real page inside an invisible frame on a hostile site, then draws a decoy on top. The user thinks they’re clicking “Play”. They’re clicking your “Delete project” button. From your server’s point of view it’s a genuine, authenticated click, because that is exactly what happened.
Here is the attacker’s page, stripped down:
<button style="position:absolute; top:120px; left:80px">Play</button>
<iframe src="https://app.flaviocopes.com/projects/42/settings"
style="position:absolute; top:0; left:0; opacity:0; width:800px; height:600px">
</iframe>
The frame is fully transparent and sits on top of the button. The user sees “Play”. The click lands on whatever your page has at that spot.
Control who may frame you
The main defense is the CSP frame-ancestors directive. It lists the origins allowed to embed the page. Keep the older X-Frame-Options header too, for clients that predate CSP:
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
With these in place, load the attacker page again. The frame stays blank and the console says Refused to display 'https://app.flaviocopes.com/' in a frame because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'". The decoy button now clicks nothing.
If one partner legitimately embeds your dashboard, name that origin instead of blocking everyone:
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com
You can set it from the application for the routes that need it:
res.set('Content-Security-Policy', "frame-ancestors 'none'")
My advice is to set 'none' globally in middleware and open a narrow allowlist only on the specific route a partner needs. It’s much easier to reason about than the other way around.
Add a barrier a hidden click can’t pass
Headers stop framing. They don’t stop a user from being tricked on your own site, or protect a client that ignores the header. For destructive actions, add a second step a single disguised click can’t complete: type the project name to confirm, or require a fresh password. One click is never enough to delete something important.
The failure that gets people
A team ships frame-ancestors 'none' everywhere and the partner’s embedded dashboard goes blank on a Monday morning. The fix is not to remove the header. It’s the allowlist above, on the dashboard route only. Find out who embeds you before you deploy, not after.
Try this on your own project: build the attacker page above on localhost and point it at a sensitive route. Show the click landing before the header change. Then add the headers, capture the browser refusal from the console, and confirm any approved embedding origin still renders.
Lesson completed