Third-party JavaScript is one of the fastest ways to lose control of your frontend security.

Analytics, chat widgets, A/B testing tools, tag managers, ad scripts, embedded dashboards — they all run with your page’s privileges unless you isolate them. If one gets compromised, your users see the blast radius, not the vendor. From the browser’s point of view, that script is your code.

This is the part many teams get wrong: XSS is not only about your own unsafe innerHTML. It is also about every script you allow to execute in your origin.

The core risk

A third-party script loaded like this:

<script src="https://cdn.vendor.example/widget.js"></script>

can:

  • read the DOM
  • steal tokens from the page
  • hook form fields
  • send requests as the user
  • inject more scripts
  • bypass assumptions in your app code

If your page contains sensitive data, that script can usually touch it.

The right mindset is simple: every third-party script is trusted code execution unless you sandbox it.

Safer loading model: start with a strict allowlist

Use CSP to decide which scripts can run. If you do nothing else, do this.

Basic CSP for third-party scripts

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.vendor.example https://analytics.example;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  report-to default-endpoint;

That policy says:

  • only your origin and two explicit vendors may run scripts
  • no old plugin content with object-src 'none'
  • no attacker-controlled <base> tricks
  • no framing

If you need implementation details, the CSP docs are here: MDN Content Security Policy and practical policy guidance at csp-guide.com.

Do not rely on host allowlists alone

A lot of teams stop at:

script-src 'self' https://trusted-cdn.example;

That is better than nothing, but weak in practice.

Why? Because if that CDN account, path, or delivery pipeline gets compromised, the browser still happily executes the payload. Host-based trust is broad trust.

A stronger pattern is nonce-based CSP for your own scripts and very limited vendor exceptions only when unavoidable.

Nonce-based CSP

Server response header:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123' 'strict-dynamic';
  object-src 'none';
  base-uri 'self';

HTML:

<script nonce="rAnd0m123">
  window.appConfig = { env: "prod" };
</script>

<script nonce="rAnd0m123" src="/assets/app.js"></script>

With 'strict-dynamic', trusted nonced scripts can load other scripts, and the browser stops relying as much on broad host allowlists. This is a much better fit for modern apps.

A few rules that matter:

  • generate a fresh nonce per response
  • never reuse static nonces
  • do not inject the nonce into attacker-controlled markup
  • avoid mixing nonce-based trust with broad legacy exceptions unless you have to

Use Subresource Integrity for fixed external files

If you must load a third-party script from a stable URL, use SRI.

<script
  src="https://cdn.vendor.example/widget-4.2.1.min.js"
  integrity="sha384-AbCdEf1234567890examplehashvalue"
  crossorigin="anonymous">
</script>

SRI tells the browser: only execute this exact file hash.

That helps when:

  • the vendor’s CDN is tampered with
  • an intermediate cache serves modified content
  • someone swaps the file contents without changing the URL

SRI does not help if:

  • the script changes frequently and you blindly update the hash
  • the vendor script dynamically loads more code
  • you load the script through a tag manager
  • you trust an endpoint that returns personalized JavaScript

My rule: use SRI for version-pinned static assets. If the script URL is mutable or operationally messy, SRI tends to rot.

Best option: self-host vendor code when licensing permits

If a vendor gives you a static JS bundle, self-host it.

<script src="/vendor/acme-widget-4.2.1.min.js"></script>

That gives you:

  • your deployment controls
  • your cache headers
  • your integrity verification in CI
  • fewer supply-chain surprises

You still trust the code, obviously. But at least you are not executing whatever appears at a remote URL during page load.

Isolate untrusted functionality in a sandboxed iframe

If the third-party feature does not need your full DOM, put it in an iframe.

<iframe
  src="/embedded-support-chat.html"
  sandbox="allow-scripts allow-forms"
  referrerpolicy="no-referrer"
  loading="lazy">
</iframe>

This is one of the few defenses that actually changes the trust boundary.

A sandboxed iframe can prevent the embedded content from:

  • navigating the top page
  • reading your DOM
  • running with your origin, depending on setup
  • using capabilities you did not grant

Be careful with allow-same-origin. A lot of developers add it casually and wipe out much of the isolation benefit.

Bad:

<iframe
  src="https://widgets.vendor.example/chat"
  sandbox="allow-scripts allow-same-origin">
</iframe>

That combination often gives the embedded app too much room. Keep the sandbox as narrow as possible.

Tag managers are XSS multipliers

I have seen more security regressions from tag managers than from handwritten script tags.

A tag manager is usually a script loader that lets non-engineering teams inject JavaScript-like behavior into production. That is operationally convenient and security-hostile.

Typical problems:

  • new vendors added without review
  • custom HTML blocks with inline script
  • event handlers that create DOM XSS
  • emergency changes bypassing normal deployment controls

If you must use one:

  • restrict who can publish
  • remove custom HTML/script capability if possible
  • use separate containers per environment
  • audit every loaded domain
  • monitor CSP violations
  • treat the tag manager as privileged code execution

Trusted Types: worth it if your app is large

Third-party scripts often turn minor DOM injection bugs into real XSS. Trusted Types helps shut down dangerous DOM sinks unless code uses approved sanitization or policy wrappers.

CSP header:

Content-Security-Policy:
  require-trusted-types-for 'script';
  trusted-types app-sanitizer default;

JavaScript:

const policy = trustedTypes.createPolicy('app-sanitizer', {
  createHTML(input) {
    return DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true });
  }
});

const safe = policy.createHTML(userSuppliedHtml);
document.getElementById('output').innerHTML = safe;
```text

With Trusted Types enabled, random code cannot just do:

```javascript
element.innerHTML = location.hash.slice(1);

That is a big deal in apps where multiple teams and vendors touch the DOM.

Docs: MDN Trusted Types API

Avoid exposing secrets to the page

If a third-party script runs in your page, assume it can read anything available to JavaScript.

That means:

  • do not store session tokens in localStorage
  • avoid long-lived bearer tokens in JS-readable places
  • prefer HttpOnly cookies for session identifiers
  • do not render secrets into the DOM “just for convenience”

Bad:

localStorage.setItem('accessToken', token);
```text

Better:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/


This does not “solve XSS”, but it reduces theft opportunities when something goes wrong.

## Load third-party code late and conditionally

Not every page needs every vendor.

Bad:

Better:

```javascript
if (document.querySelector('[data-needs-chat]')) {
  const s = document.createElement('script');
  s.src = 'https://cdn.chat.example/chat.js';
  s.async = true;
  document.head.appendChild(s);
}

This is not a direct XSS mitigation by itself, but it cuts exposure. Fewer pages loading fewer vendors means fewer places an attacker can hide.

Watch for dangerous integration patterns

These are common and bad.

1. Vendor bootstrap with inline config from user data

<script>
  vendor.init({
    accountName: "{{ user.accountName }}",
    theme: "{{ request.query.theme }}"
  });
</script>

If that templating is not context-safe, you just created XSS before the vendor script even starts.

Safer:

<script type="application/json" id="vendor-config">
{
  "accountName": "Acme Corp",
  "theme": "light"
}
</script>

<script nonce="{{ .CSPNonce }}" src="/assets/vendor-init.js"></script>
const config = JSON.parse(document.getElementById('vendor-config').textContent);
vendor.init(config);
```text

### 2. Passing HTML into widgets

```javascript
chatWidget.render({
  welcomeHtml: user.bio
});

Unless the API explicitly sanitizes input and you trust it, assume this is unsafe.

Prefer plain text:

chatWidget.render({
  welcomeText: user.bio
});
```text

### 3. JSONP-style script endpoints

This is remote code execution by design. Avoid it.

Use `fetch()` with CORS-enabled JSON instead:

```javascript
const res = await fetch('https://api.vendor.example/data', {
  credentials: 'omit'
});
const data = await res.json();
handleData(data);

A decent production checklist

Use this when reviewing any third-party script:

  • What exact capability does it need?
  • Can it be replaced with server-side integration?
  • Can it run in a sandboxed iframe?
  • Can I self-host it?
  • Can I pin it with SRI?
  • Is it covered by a strict CSP?
  • Does it require unsafe inline script?
  • Does it dynamically load more scripts?
  • What data can it read from the page?
  • Who can change or publish it?
  • How do I remove it quickly if it is compromised?

If your answer to most of these is “not sure,” you are not integrating a script. You are granting unsupervised code execution.

Copy-paste baseline

A practical starting point for pages that need a small number of vendors:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{RANDOM_NONCE}}' 'strict-dynamic' https://cdn.vendor.example;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.vendor.example;
  frame-src https://widgets.vendor.example;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  require-trusted-types-for 'script';
<script
  src="https://cdn.vendor.example/widget-4.2.1.min.js"
  integrity="sha384-AbCdEf1234567890examplehashvalue"
  crossorigin="anonymous">
</script>
<iframe
  src="https://widgets.vendor.example/embed"
  sandbox="allow-scripts allow-forms"
  loading="lazy">
</iframe>

That is not perfect, but it is a lot better than dropping random vendor code into <head> and hoping for the best.

Official docs worth keeping handy:

Third-party scripts are not harmless dependencies. They are code execution contracts. Treat them that way.