Web security

Can someone put your protected app in an iframe?

This question arrives most often in a form that already contains a wrong assumption: “we turned on domain locking, so nobody can embed our app, right?” The honest answer is no, and it is worth walking through the mechanism rather than just asserting it, because once you see why the guard passes inside a frame it becomes obvious which layer the real control lives at.

Why the domain guard passes inside a frame

The domain lock emits a small function that runs before your code. It reads location.hostname from the global object, lowercases it, and compares it against the list of domains you allowed, optionally accepting subdomains. If it matches, execution continues. If it does not, the configured failure action runs.

Now consider what happens when attacker.example embeds <iframe src="https://yourapp.com/dashboard">. The browser fetches your page from your server and creates a document for it inside that frame. That document’s location is your URL. So when your guard asks for location.hostname, the answer is yourapp.com — your own domain, on your allowlist — and the guard does exactly what it should: it permits execution.

This is not a defect. The domain lock answers “is this code being served from a host I authorised?” and in the framing scenario the honest answer is yes: it is still your host, still your file, still your certificate. The attacker did not copy your code anywhere. They pointed at it. Nothing about where the code came from has changed, and that is the only question the guard is in a position to ask.

The lesson generalises past this one option. An in-page check can only see what the page can see, and framing is a property of the relationship between two browsing contexts, which your document is not the authority on.

The control that does work

Framing is decided by the browser, before your JavaScript runs, based on a header your server sends. That is precisely why it is reliable.

Content-Security-Policy: frame-ancestors is the current mechanism. It names which origins may embed your document, and 'none' forbids all of them. This is the one to configure.

X-Frame-Options is the older header, supporting DENY and SAMEORIGIN. It offers no way to express an allowlist of several origins, so where you need one, use frame-ancestors. Sending both is harmless and helps older clients.

Two operational notes. This is a per-response header, so it must be set on the documents you care about rather than only on your home page — and a payment, consent or authentication step is exactly the sort of endpoint attackers want framed. And frame-ancestors is not honoured when a policy is delivered by <meta> tag, so it must come from the actual HTTP response.

Because this is a server configuration change rather than a build-step one, it belongs with the other headers discussed in obfuscation and Content Security Policy, and it is entirely independent of how your JavaScript was protected.

Frame-busting script loses to one attribute

The traditional fallback is a script that compares window.self with window.top and, on finding itself framed, navigates the top window away or hides its own content. It is worth understanding why this is weak, since obfuscating it is a common instinct and does not help.

An embedder can apply the sandbox attribute to the frame. Without allow-top-navigation, the frame is simply not permitted to navigate the top-level context, so the escape attempt fails silently and your application carries on rendering inside the frame. The attacker did not defeat your check by reading it. They withheld a capability from the frame, and the browser enforced that against your code.

Which is the reason obfuscation is not the relevant tool: the restriction is imposed on your execution context rather than derived from understanding your logic. Making the check unreadable leaves it just as unable to act. Sandboxing can also strip the frame to an opaque origin, which as the next section shows has its own consequence for the domain guard.

The empty-hostname case, which bites legitimate users

There is a related behaviour worth testing for deliberately, because it produces support tickets that are hard to diagnose.

The domain guard treats an empty hostname as a failure before it consults your allowlist: if location.hostname yields an empty string, the failure action fires immediately. Several ordinary contexts produce exactly that:

  • Frames created with srcdoc, and anything loaded into about:blank.
  • Documents from a data: or blob: URL.
  • Content loaded from the local filesystem with a file:// URL, including packaged application shells and offline documentation.

So the practical shape of the problem is the reverse of the one people worry about. Domain locking will not stop a hostile embedder, and it may well break your own preview iframe, your embedded sandbox, or a customer’s webview that loads from disk. The same failure mode is described from the device end in protecting JavaScript on embedded devices. Enumerate every context your bundle legitimately loads in, and test the lock in each one before shipping it.

Clickjacking is not a code-reading attack

It is worth naming the threat that framing protections exist for, because it clarifies why obfuscation has no role.

In a clickjacking attack the attacker frames your genuine application, renders it transparent or positions it beneath appealing bait, and induces a click that lands on your real interface. Every request that results is legitimate: real session, real user, an action your application offers. Nothing is read, nothing is modified, nothing is reverse-engineered. There is no code-protection surface at all.

The defences are refusing to be framed, and designing consequential actions so that one misdirected click cannot complete them — a confirmation step, a re-authentication, a typed value. That second half is the same reasoning as elsewhere on this site: put the decision somewhere the attacker’s framing cannot reach, rather than hardening the click handler.

When you want to be embedded

All of the above assumes framing is unwanted. For a widget or an SDK it is the entire product, and the control set inverts.

There, domain locking becomes the primary control rather than an irrelevant one, because the question genuinely is which customer hosts are entitled to run your code, and that is the question the guard answers well. Per-customer builds with a watermark make a leaked copy attributable, and the host page’s ability to observe everything your code does becomes a design constraint rather than an attack. That whole model is covered in protecting a widget or distributed SDK.

Between the two cases, an allowlist is usually the right answer rather than a blanket denial: name your own origins and your partners’ in frame-ancestors, and refuse everyone else.

The short version

Domain locking does not prevent framing, because inside a frame your document still reports your own hostname and the guard correctly permits it; that option answers where code is served from, not who wrapped it. Use Content-Security-Policy: frame-ancestors from the actual HTTP response, on every sensitive endpoint rather than just the home page, with X-Frame-Options alongside for older clients. Do not rely on frame-busting script, which the sandbox attribute defeats without reading a line of it. Test the domain lock in every context you legitimately load in, since an empty hostname fires the failure action before the allowlist is consulted. And if being embedded is the product rather than the threat, the same option becomes your main control, used as a customer allowlist.

Frequently asked questions

Does domain locking stop my application being embedded in an iframe?

No, and the reason is worth knowing precisely. The domain guard reads location.hostname of the document it is running in. When your page is loaded inside a frame, that document is still your page served from your host, so hostname returns your own domain, matches the allowlist and the guard passes normally. Domain locking answers the question of where the code is hosted from, and framing does not change that answer. Preventing embedding is a different control at a different layer.

What actually prevents another site from framing my application?

A response header, sent by your server and enforced by the browser. Content-Security-Policy with a frame-ancestors directive is the current mechanism, and it lets you name exactly which origins may frame you or forbid all of them. X-Frame-Options is the older header with DENY and SAMEORIGIN values and remains useful for older clients. Both are enforced by the browser before your JavaScript runs, which is what makes them reliable in a way that no in-page check can be.

Is frame-busting JavaScript a reasonable fallback?

It is weak, and it is weak by design rather than by accident. A frame-busting script compares the current window with the top window and tries to escape or blank itself. An embedding page can apply the sandbox attribute to the frame, which withholds the capability to navigate the top-level context, so the escape simply fails while your code continues to run. Obfuscating the check does not change this, because the restriction is imposed by the browser on the frame rather than by anyone reading your code. Use the header, and treat script as a cosmetic extra at most.

Does obfuscation help against clickjacking?

No, because clickjacking does not involve reading or modifying your code. The attacker loads your genuine page in a frame, makes it transparent or positions it under something enticing, and the victim clicks your real interface while believing they are clicking something else. Every action taken is one your application legitimately offers, executed by a real user in a real session. There is nothing for code protection to act on. The defence is refusing to be framed, plus confirmation steps for consequential actions that a single misdirected click should not complete.

Why does my domain-locked bundle fail in a preview or an embedded webview?

Because the guard treats an empty hostname as a failure before it ever consults the allowlist. Documents loaded from about:srcdoc, about:blank, a data URL, a blob URL or the local filesystem report an empty location.hostname, so the guard fires its failure action immediately. That catches several legitimate contexts: srcdoc-based previews and sandboxes, some email and documentation renderers, packaged application shells loading from disk, and code injected into a blank frame. Test every delivery path you support before enabling the lock rather than discovering it from a support ticket.

If I want to be embedded, what protects my code on someone else's page?

A different set of controls, because being embedded is the intended behaviour rather than the threat. When your widget or SDK is meant to run on customer pages, domain locking is the primary control and it is used as an allowlist of the customers entitled to run it, with optional subdomain matching. Per-customer builds carrying a watermark make a leaked copy attributable. It is worth designing for this case explicitly, because the host page can observe everything your code does and share the global environment with it.

Can I detect that my page is being framed and report it?

Yes, and reporting is a much better use of the signal than reacting. Comparing window.self with window.top tells you whether you are framed, and if a hostile embedder has withheld cross-origin access even the failure to read it is informative. Sending that to your own telemetry gives you evidence of who is embedding you, which is useful commercially and for abuse reports. What you should not do is make an access decision in the page based on it, since the embedder controls the frame and can prevent your reaction from having any effect.

Related reading