Plotly.js is great until you put a real Content Security Policy in front of it.

I’ve seen this go the same way a bunch of times: charts work fine in local dev, someone adds a decent CSP in staging, and suddenly the graph is blank, console errors pile up, and the quick “fix” is to throw 'unsafe-inline' and 'unsafe-eval' into the policy. That usually gets the chart rendering again, but it also guts the point of having CSP.

If you’re running Plotly.js on a site with a strict policy, the failures usually come from a few predictable mistakes. Here’s what breaks, why it breaks, and how I’d fix it without turning CSP into theater.

Mistake #1: Assuming default-src covers everything Plotly needs

A lot of teams start with a policy like this:

Content-Security-Policy:
  default-src 'self';

Then they load Plotly and wonder why rendering fails.

default-src is just a fallback. Plotly-heavy pages commonly need separate rules for:

  • script-src
  • style-src
  • img-src
  • connect-src
  • worker-src

If you fetch chart data from an API, export images, or use features that rely on blobs or workers, default-src alone won’t save you.

A more realistic baseline looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: blob:;
  connect-src 'self' https://api.example.com;
  worker-src 'self' blob:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

If you want a reference for what each directive actually does, csp-guide.com is a good quick lookup.

Mistake #2: Loading Plotly from a CDN but forgetting script-src

This one is basic, but it still happens all the time.

You add:

<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>

But your policy only allows self-hosted scripts:

script-src 'self';

That gets blocked immediately.

Fix

Either self-host Plotly or explicitly allow the official CDN.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.plot.ly;

If you already use nonces and a stricter script policy, keep doing that. Don’t loosen the whole thing just for one library.

For example, this real-world header from headertest.com shows a pretty modern script policy:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-Y2MwZjlkNDQtMDUwOS00ZGU5LTg1MDgtMDFlODRlMmNhZDI4' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none'

If you wanted to permit CDN-hosted Plotly in that style of policy, you’d extend script-src instead of falling back to 'unsafe-inline':

script-src 'self' 'nonce-<random>' 'strict-dynamic' https://cdn.plot.ly https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;

Mistake #3: Using inline initialization code without a nonce

This is probably the most common Plotly CSP breakage.

People load the library correctly, then initialize the chart inline:

<div id="chart"></div>
<script>
  Plotly.newPlot('chart', [{
    x: [1, 2, 3],
    y: [10, 15, 13],
    type: 'scatter'
  }]);
</script>

If your script-src does not allow inline scripts, that block is blocked.

Fix

Best fix: move the code into an external JS file.

<div id="chart"></div>
<script src="/static/js/chart.js"></script>
// /static/js/chart.js
Plotly.newPlot('chart', [{
  x: [1, 2, 3],
  y: [10, 15, 13],
  type: 'scatter'
}]);

If you really need inline script during server rendering, use a nonce:

<div id="chart"></div>
<script nonce="{{ .CSPNonce }}">
  Plotly.newPlot('chart', [{
    x: [1, 2, 3],
    y: [10, 15, 13],
    type: 'scatter'
  }]);
</script>

And the header:

Content-Security-Policy:
  script-src 'self' 'nonce-{{RANDOM_NONCE}}';

If you’re already using 'strict-dynamic', nonce-based bootstrapping is the clean way to do this.

Mistake #4: Forgetting that Plotly may need inline styles or dynamic styling

Plotly manipulates the DOM heavily. Depending on your setup and the version/features you use, you may run into style-src violations.

A lot of teams “solve” this with:

style-src 'self' 'unsafe-inline';

That works, but I don’t love it. It’s common in production because CSS nonces and hashes are more awkward than script nonces, but you should still treat 'unsafe-inline' as a compromise, not a default.

Fix

Start strict and only loosen if you prove Plotly or your own code needs it.

Try:

style-src 'self';

If charts fail because of style injections you can’t reasonably avoid, use the narrowest relaxation that works in your app. Sometimes that means accepting:

style-src 'self' 'unsafe-inline';

That’s not ideal, but it can be a practical tradeoff, especially if the rest of the policy is locked down.

The real mistake isn’t using 'unsafe-inline' in style-src after testing. The real mistake is adding it blindly before you know what’s actually being blocked.

Mistake #5: Blocking API calls with a too-strict connect-src

Plotly itself can render from local data just fine. But most real apps fetch data before calling newPlot().

Example:

async function renderChart() {
  const res = await fetch('https://api.example.com/metrics');
  const data = await res.json();

  Plotly.newPlot('chart', [{
    x: data.timestamps,
    y: data.values,
    type: 'bar'
  }]);
}

If your policy says:

connect-src 'self';

that API request is blocked.

Fix

Allow the exact origins used for chart data:

connect-src 'self' https://api.example.com;

If you use WebSockets for live updates, include those too:

connect-src 'self' https://api.example.com wss://stream.example.com;

You can see this pattern in the headertest.com policy above. Their connect-src is much broader because the app talks to multiple analytics, API, and websocket endpoints. That’s normal. connect-src needs to reflect actual runtime behavior, not your idealized architecture diagram.

Mistake #6: Forgetting img-src data: for exported or generated chart assets

Plotly can generate images, and some chart rendering paths or UI elements may rely on data: or blob: URLs.

A strict image policy like this:

img-src 'self';

can break those features.

Fix

Allow the schemes you actually need:

img-src 'self' data: blob:;

If your app also loads remote images inside chart layouts, add those origins explicitly:

img-src 'self' data: blob: https://images.example.com;

Don’t jump straight to img-src * data: blob: unless you enjoy debugging future policy sprawl.

Mistake #7: Missing worker-src blob: when features use workers

This one is easy to miss because not every chart path will trigger it.

Some JavaScript libraries use blob-backed workers or worker-like execution paths. If your app or Plotly-related tooling creates workers from blob: URLs, this policy blocks them:

worker-src 'self';

Fix

Permit blob workers if you confirm they’re needed:

worker-src 'self' blob:;

If you don’t set worker-src, CSP may fall back to other directives depending on browser behavior, which makes debugging more annoying than it should be. I prefer being explicit.

Mistake #8: Using CSP hashes for dynamic chart bootstrapping

Hashes are fine for truly static inline scripts. Plotly setup code often isn’t static.

A lot of apps inject server-side data into the chart config:

<script>
  Plotly.newPlot('chart', {{ chartData }}, {{ chartLayout }});
</script>

That means the script content changes per request, so your hash changes too. Teams try to automate hash generation, then regret the complexity a week later.

Fix

Use one of these instead:

  1. External JS plus JSON data in the DOM
  2. A nonce on the inline bootstrapping script

My preference is external JS with serialized data:

<div id="chart"></div>
<script type="application/json" id="chart-data">
  {"x":[1,2,3],"y":[4,5,6],"type":"scatter"}
</script>
<script src="/static/js/render-chart.js"></script>
const raw = document.getElementById('chart-data').textContent;
const trace = JSON.parse(raw);

Plotly.newPlot('chart', [trace]);

That keeps your executable code static and your CSP simpler.

Mistake #9: Debugging CSP from the final blocked error only

When Plotly fails under CSP, the visible symptom is usually useless: blank chart, half-rendered toolbar, export button not working.

The browser console gives the real answer. So does Content-Security-Policy-Report-Only.

Fix

Roll out changes in report-only mode first:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' https://cdn.plot.ly;
  style-src 'self';
  img-src 'self' data: blob:;
  connect-src 'self' https://api.example.com;
  worker-src 'self' blob:;
  object-src 'none';
  base-uri 'self';

Then watch what gets flagged before enforcing it.

This is especially useful if your page already has a more complex policy, like the headertest.com example. Once analytics, consent tooling, websockets, APIs, and frontend libraries all mix together, guessing is slower than just reading the reports.

A sane CSP starting point for Plotly.js

If I were setting up Plotly on a fairly standard app today, I’d start around here and tighten or extend based on actual behavior:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.plot.ly 'nonce-{{RANDOM_NONCE}}';
  style-src 'self';
  img-src 'self' data: blob:;
  connect-src 'self' https://api.example.com;
  worker-src 'self' blob:;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

If Plotly or your surrounding UI stack genuinely requires inline styles, I’d relax only that part:

style-src 'self' 'unsafe-inline';

That’s a lot better than the usual panic policy:

script-src 'self' 'unsafe-inline' 'unsafe-eval' *;
style-src 'self' 'unsafe-inline' *;

I’ve seen plenty of teams end up there under deadline pressure. It gets the demo working. It also makes CSP mostly decorative.

With Plotly.js, the trick is not fighting the library blindly. Be specific. Allow the exact script origin, the exact API origins, data: and blob: where the chart features need them, and use nonces for bootstrapping code. That gets you a working chart and a policy that still means something.

For the underlying directive details, the official CSP documentation on MDN and the quick directive reference at csp-guide.com are the two places I’d keep open while tuning the policy.