PLAINFINCH learn by building

Clickjacking

You will block a bike shop page from being hidden in an invisible frame, using one response header.

What is clickjacking?

Clickjacking tricks a visitor into clicking something on your site while they believe they are clicking something on another site. The attacker's page loads your real page inside an <iframe>, makes the frame fully transparent with CSS, and lines it up so your button sits exactly over a button of their own. The visitor sees a game or a prize draw and clicks Play. The click goes to the invisible page on top.

Because the frame shows your real page, the visitor's own cookies come with it. If they are signed in to Spoke and Chain, the click on Book with my saved card is a genuine click by a genuine signed-in customer, and the shop has no way to tell it apart from an intended one.

The same idea can trigger anything that takes one or two clicks: deleting an account, changing a privacy setting, approving a payment, or granting a camera permission. It is also called UI redressing, since the attacker dresses up your interface as something else.

FigureLeft: what the visitor sees, an evil.example prize page with a Play button. Middle: what is really on top, the bike shop's booking page in a transparent iframe, its Book with my saved card button lined up over Play, so the click books a bike. Right: with Content-Security-Policy frame-ancestors 'self', the browser draws an empty frame and the click hits nothing of yours.Left: what the visitor sees, an evil.example prize page with a Play button. Middle: what is really on top, the bike shop's booking page in a transparent iframe, its Book with my saved card button lined up over Play, so the click books a bike. Right: with Content-Security-Policy frame-ancestors 'self', the browser draws an empty frame and the click hits nothing of yours.
The attacker puts your real page in an invisible frame over their own button, so the visitor's click goes to you. A frame-ancestors header makes the browser refuse to draw your page inside another site.

Why didn't SameSite cookies stop it?

In the CSRF lesson, SameSite=Lax kept the login cookie off forms posted from evil.example. It helps here too: current browsers leave Lax and Strict cookies off a page that another site loads in a frame, so the framed page would usually appear signed out. The lab does not model this and treats the frame as signed in.

That protection is partial. Sites that use SameSite=None for embedded widgets or single sign-on send the cookie into frames, browsers differ in how they partition third-party cookies, and some clicks do harm without a login at all, such as granting a camera permission or pressing a button on a public form.

The reliable fix is to stop the page appearing inside other sites' frames in the first place. That is a decision the browser makes, based on a header your server sends.

How does frame-ancestors work?

frame-ancestors is a setting inside the Content-Security-Policy header. It lists which sites may show this page inside a frame. Before drawing a framed page, the browser checks the address of every page above it, the parent and any frames around that, against the list. If it does not match, the browser shows an empty box instead of your page, and logs an error in the Console.

Content-Security-Policy: frame-ancestors 'self'
  • frame-ancestors 'none': no site may frame the page, including your own.
  • frame-ancestors 'self': only pages from the same origin may frame it. This suits a shop that frames nothing or only its own pages.
  • frame-ancestors 'self' https://partners.spokeandchain.example: your own pages plus one named site.
  • frame-ancestors https: or *: almost anyone, which is the setting the Break it exercise fixes.

Where does X-Frame-Options fit in?

X-Frame-Options is the older header for the same job, from before frame-ancestors existed. It has two useful values: DENY, which allows no framing, and SAMEORIGIN, which allows framing by your own origin. Every current browser still honours them.

Its third value, ALLOW-FROM followed by an address, was never supported by Chrome or Safari and has been dropped by Firefox. A browser that does not understand the value ignores the whole header, leaving the page open. When both headers are present, browsers follow frame-ancestors and ignore X-Frame-Options.

A sensible setup sends frame-ancestors as the main rule and X-Frame-Options: DENY or SAMEORIGIN alongside it for very old browsers. If your site already sends a Content Security Policy, add frame-ancestors to it, separated by a semicolon, rather than writing a second policy.

Why doesn't a meta tag work for this?

Some settings of Content Security Policy can be placed in the page as <meta http-equiv="Content-Security-Policy" content="...">. frame-ancestors is one of the exceptions: browsers ignore it there. The reason is timing. The browser needs to decide whether to draw the page in a frame before it reads the HTML, so the rule has to arrive as a real response header.

If you host on a service where you cannot set headers easily, look for its header settings file. Netlify uses _headers, Vercel uses vercel.json, and Apache uses .htaccess with Header set. Cloudflare can add headers with a Transform Rule. In Express, the helmet package sets both framing headers for you.

How do you check whether a page can be framed?

Open the page, then DevTools, and choose the Network tab. Reload, click the first request (the page itself), and read its Response Headers. Look for content-security-policy containing frame-ancestors, or for x-frame-options.

To see the block happen, make a small local HTML file containing only <iframe src="https://your-site.example/"></iframe> and open it. A protected page shows an empty frame, and the Console says the page refused to be framed. Only test your own pages this way.

When should a page allow framing?

Some pages are meant to be embedded: a booking widget for partner cycling clubs, a map, a video player. Those pages can have their own, wider frame-ancestors list naming the sites that embed them, while the rest of the shop stays at 'self'. Keep anything that changes data, takes payment or shows account details off embeddable pages. A confirmation step that asks for typing, such as entering the date again before a booking, also makes a single hidden click far less useful.

How does the lab simplify clickjacking?

The lab checks your headers against one attacker site and one invisible frame. It treats frame-ancestors lists containing *, https: or evil.example as open and everything else as closed. Real browsers also apply cookie partitioning, permission rules and frame sandboxing, which the lab leaves out. Real headers are often set in several places, such as the app, a proxy and a CDN, and one can override another, so check the response the browser actually receives.

What do AI tools get wrong about clickjacking?

Asked to let a partner site embed a page, assistants frequently write X-Frame-Options: ALLOW-FROM https://partner.example, which current browsers ignore entirely. Others suggest JavaScript "frame-busting" code that checks window.top. Those scripts can be blocked by the framing page with the sandbox attribute, so they should never be the main defence.

When a framed page stops working during development, an assistant may suggest removing X-Frame-Options or adding frame-ancestors * to every page. Ask which single page needs to be framed and by which site, and widen the rule for that page only.

Field notes

  • Clickjacking hides your page in a see-through frame so visitors click your buttons unknowingly.
  • Content-Security-Policy: frame-ancestors 'none' stops any site from framing the page.
  • X-Frame-Options: DENY covers older browsers, and ALLOW-FROM no longer works.
  • frame-ancestors has no effect in a meta tag, so send it as a header.

Easy to get wrong

ALLOW-FROM is obsolete, and browsers ignore the whole header. frame-ancestors can name sites and is supported everywhere.

Browsers ignore frame-ancestors in a meta tag because they decide on framing before reading the HTML.

The framing page can sandbox the frame and stop your script from running. The header is enforced by the browser.

Questions people ask

Does every page need framing protection?

Every page that has buttons or forms a signed-in visitor could be tricked into using. Setting it site-wide and widening it for the few embeddable pages is simplest.

Will frame-ancestors 'self' break my own iframes?

No. Your pages can still frame each other. 'none' would block your own pages too.

Does frame-ancestors affect pages I embed from other sites?

No. It controls who may frame your page. Which sites your page may frame is set by frame-src, a different setting.

Is clickjacking still common?

Less than it was, since many frameworks and hosts now send framing headers by default. Small sites with hand-written servers often still lack them.

Keep going