# Embedding an AI widget on a client's site: iframe or Web Component, and how to read the CSP

> The widget works on staging. On the client's production site it shows up as an empty box. The cause is nearly always the boundary between the two pages, and rarely your code.

Original: https://fdetimes.net/en/guides/embed-ai-widget-iframe-web-component-csp/

Picture this. The staging demo runs smoothly, the client approves, and the go-live date is fixed. On the morning you deploy to the client's real customer portal, the AI assistant's slot is an empty white box, and the console shows one red line: "Refused to frame".

None of your code has changed. The page around it has. The client's production site has a strict Content Security Policy. Staging had none.

An engineer who can read the boundary between the two pages fixes this in a morning. One who can't spends a week trading emails with the client's security team.

This guide shows how to read that boundary. It covers how to choose between an iframe and a Web Component, what the client has to open for each, and how to let the two pages talk without opening a new hole.

## An iframe has two gatekeepers

Chrome for Developers' documentation on CSP starts from a plain fact: by default, the browser runs any code a page asks for. So a page must declare an allowlist of the sources it trusts. With an iframe, there are two allowlists, one on each side.

On the client's page, `frame-src` sets where that page may load iframes from. On your widget's page, `frame-ancestors` sets which parent pages may embed it. MDN is explicit that these two directives point in opposite directions. When you see a blank box, the first question is: which side is refusing?

Two traps often lead to the wrong diagnosis. The first: if the client's CSP has no `frame-src`, the browser falls back to `child-src`, then to `default-src`. A harmless-looking `default-src 'self'` is enough to block your iframe, even while the client insists they "don't block any frames".

The second trap is on your side. `frame-ancestors` has no effect when declared in a `` tag. It must be sent as an HTTP header. If the widget sits on a static host and the policy lives in the HTML, you have in effect declared nothing. Configure it at the CDN or the server.

## Iframe or Web Component: choose the boundary first, the technology second

Web Components are three technologies: custom elements, Shadow DOM and HTML templates. According to MDN, their purpose is encapsulation, so that names and styles clash less with the host page. Shadow DOM keeps the widget's styles and IDs separate from the rest of the page. The client's CSS won't break your buttons, and your CSS won't break their page.

Here is the common misunderstanding. This is isolation of the interface, not security isolation. A custom element runs inside the client's document.

The consequence follows directly. Because it lives in the client's page, the widget's scripts and API calls must pass the page's CSP like every other script there. An iframe on its own origin loads the widget as a separate document, and the two sides communicate through `postMessage`, a channel where you control both ends.

**Key point:** An embedded widget runs by the client page's rules, not yours.

That gives a simple rule for choosing. If the widget needs to blend into the interface and read page state, and the client accepts running your code in their page, a Web Component makes sense. If the widget holds tokens, conversation history or end-user data that you don't want any other script on the page to touch, choose an iframe.

## Example: a voice assistant in a customer portal

Suppose you are adding a voice-enabled AI assistant to `https://app.customer.example`. The widget lives at `https://widget.vendor.example` and the API at `https://api.vendor.example`. You choose an iframe because the widget holds the conversation session. First, declare your side with a header:

```http
Content-Security-Policy: frame-ancestors https://app.customer.example
```

On the client's side, suppose the current header contains only `default-src 'self'`. Under the fallback rule, the iframe will be blocked. Ask the client to add exactly one directive rather than loosening `default-src`:

```http
Content-Security-Policy: default-src 'self';
  script-src 'self' 'nonce-{fresh-value-per-request}' 'strict-dynamic';
  frame-src https://widget.vendor.example
```

The snippet that loads the widget is an inline script, so it needs a nonce. Infobip, in its documentation for the Live Chat widget, recommends a nonce and discourages `unsafe-inline` unless the client's directive already contains it. Chrome for Developers adds that the nonce must be generated fresh for every request and must not be guessable. A nonce hard-coded into a template protects nothing.

Why `strict-dynamic`? Atlassian explains in its widget documentation that the widget's loader script is updated often, so a hash-based CSP is a poor fit, and it recommends `strict-dynamic`.

Atlassian also asks clients to open `frame-src` to the widget host, exactly as in the example above.

Now the voice part. The iframe tag needs an `allow` attribute:

```html
<iframe src="https://widget.vendor.example/chat?tenant=customer"
        allow="microphone"
        sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
```

MDN notes that `allow` can only narrow the policy already set by the page's `Permissions-Policy` header. It does not replace that header. If the client sends `Permissions-Policy: microphone=()`, your microphone button will stay silent whatever the iframe tag says. Ask about this during discovery, not on go-live day.

Read the `sandbox` attribute carefully. MDN warns strongly against combining `allow-scripts` and `allow-same-origin` when the embedded content shares an origin with the parent page, because the embedded page can then remove its own sandbox.

The configuration above is acceptable only because the widget is on a separate origin. If the client one day proposes proxying the widget to `app.customer.example/assistant` to "keep things tidy", that is the moment to drop one of the two flags.

## postMessage is a back door if you forget to check the origin

The client's page often needs to pass context into the widget, such as which order the user is viewing. The widget needs to report out, for instance that the conversation has been handed to a human agent. The standard channel for this is `postMessage`, and MDN sets two rules for it.

When sending, always specify a concrete `targetOrigin`, never `"*"`, because the target window may have navigated elsewhere and the data could land with a stranger. When receiving, check `origin`, and preferably `source` too. MDN says plainly that skipping this step opens the way to cross-site scripting attacks.

```js
const WIDGET = 'https://widget.vendor.example';
const frame = document.querySelector('iframe');

frame.contentWindow.postMessage({ type: 'context', orderId: 'A123' }, WIDGET);

window.addEventListener('message', (e) => {
  if (e.origin !== WIDGET) return;
  if (e.source !== frame.contentWindow) return;
  if (e.data?.type === 'handoff') openAgentPanel(e.data.ticketId);
});
```

The widget does the same in the other direction. Its list of valid origins should come from each tenant's configuration rather than being hard-coded.

## The mistakes that delay go-live by a week

The most common mistake is fixing the wrong side: asking the client to loosen their CSP when the block is your own `frame-ancestors`, or the reverse. The second is asking for too much. The client will refuse `default-src *` or `unsafe-inline`, and they will be right to.

The third is assuming that an iframe only needs `frame-src`. Infobip states that for its Live Chat widget, which loads through an iframe, clients must still open `script-src`, `connect-src`, `frame-src` and `style-src` together.

The reason is that even when the widget sits in an iframe, the code that loads it on the client's page still has to pass the client's CSP. So hand over the complete list at the start, rather than having them open one directive after each failure.

## What should you bring to the meeting with the security team?

That list should become a written document: "CSP requirements for embedding the widget", listing each directive, each domain and the reason it is needed. With it, the meeting goes line by line instead of circling through questions.

The document is also the best evidence for your CV. Instead of "frontend integration", write that you designed the embedding configuration and CSP requirements that took a widget to production on a system with a strict security policy.

Security teams trust people who understand their policy more than people who just ask them to open things up.

**Try this week:**

- Open DevTools on a page with a strict CSP (a bank or a large SaaS product), read the Content-Security-Policy header and answer for yourself: would an iframe from an unknown domain load, and if so, which directive allows it?
- Build an HTML page that embeds an iframe from a second origin running on localhost, wire up two-way postMessage with origin checks, then send a message from a third origin to confirm it is rejected.
- Draft a "CSP requirements for embedding the widget" page for the product you work on, listing each directive, each domain and the reason.

## Sources

- [Content-Security-Policy: frame-ancestors directive - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors)

- [Content-Security-Policy: frame-src directive - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-src)

- [<iframe> HTML inline frame element - HTML | MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe)

- [Web Components - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)

- [Window: postMessage() method - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)

- [Content Security Policy (CSP)](https://www.infobip.com/docs/live-chat/content-security-policy-csp-in-live-chat)

- [About the widget security policy | Customer Service Management | Atlassian Support](https://support.atlassian.com/customer-service-management/docs/about-the-widget-security-policy/)

- [Content Security Policy | Privacy & Security | Chrome for Developers](https://developer.chrome.com/docs/privacy-security/csp)
