D3.js v7 is pretty friendly to Content Security Policy compared to older frontend stacks. It does not need eval, it does not inject mystery scripts, and it mostly sticks to normal DOM APIs. That’s the good news.

The bad news is that D3 usually lives inside apps that do all the annoying CSP-breaking stuff around it: inline bootstrapping, dynamic data loading, CSS in <style> blocks, analytics tags, and third-party embeds. So the trick is not “make D3 work.” The trick is “make D3 work without punching a giant hole in your policy.”

I’ve had the best results treating D3 as just another rendering library and then building a CSP around the actual delivery pattern:

  • D3 loaded from self
  • app bootstrap script with a nonce or external file
  • data fetched from approved APIs
  • no inline event handlers
  • no unsafe-eval
  • ideally no unsafe-inline for scripts

What D3 v7 needs from CSP

For a typical chart page, D3 itself only needs a few things:

  • script-src to load D3 and your app script
  • connect-src if you fetch CSV, JSON, TSV, or API data
  • img-src if you embed images in SVG or HTML tooltips
  • style-src if you use inline styles or <style> tags
  • font-src if your chart UI loads webfonts

D3 does not require:

  • unsafe-eval
  • wasm-unsafe-eval
  • object-src anything other than 'none'

That makes D3 a good fit for a strict policy.

Start with a tight baseline

Here’s a solid CSP for a D3 page where everything is hosted locally and chart data is fetched from your own API:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  object-src 'none';

A few opinions:

  • I like default-src 'self' as a fallback, but I still set explicit directives for anything important.
  • script-src should use nonces if you have any inline bootstrap script at all.
  • strict-dynamic is worth using when you trust nonce-bearing scripts more than host allowlists. It simplifies script loading and reduces the chance you keep stale domains around.
  • object-src 'none' should be muscle memory at this point.

If you want directive-by-directive background, the official CSP spec docs are useful, and https://csp-guide.com is handy when you need a plain-English refresher.

A minimal D3 page that works with CSP

Let’s build a bar chart without any inline event handlers or sketchy script patterns.

HTML

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>D3 CSP Demo</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="/assets/chart.css">
</head>
<body>
  <main>
    <h1>Sales chart</h1>
    <div id="chart"></div>
  </main>

  <script src="/assets/d3.v7.min.js"></script>
  <script nonce="{{ .CSPNonce }}" src="/assets/chart.js"></script>
</body>
</html>

If both scripts are external and served from your origin, you can even skip the nonce on chart.js. I still tend to keep one when templating pages that may later gain a tiny inline bootstrap.

JavaScript

const data = [
  { label: "Jan", value: 12 },
  { label: "Feb", value: 18 },
  { label: "Mar", value: 9 },
  { label: "Apr", value: 22 }
];

const width = 640;
const height = 320;
const margin = { top: 20, right: 20, bottom: 40, left: 40 };

const svg = d3.select("#chart")
  .append("svg")
  .attr("width", width)
  .attr("height", height)
  .attr("viewBox", `0 0 ${width} ${height}`);

const x = d3.scaleBand()
  .domain(data.map(d => d.label))
  .range([margin.left, width - margin.right])
  .padding(0.2);

const y = d3.scaleLinear()
  .domain([0, d3.max(data, d => d.value)])
  .nice()
  .range([height - margin.bottom, margin.top]);

svg.append("g")
  .attr("transform", `translate(0,${height - margin.bottom})`)
  .call(d3.axisBottom(x));

svg.append("g")
  .attr("transform", `translate(${margin.left},0)`)
  .call(d3.axisLeft(y));

svg.selectAll("rect")
  .data(data)
  .join("rect")
  .attr("x", d => x(d.label))
  .attr("y", d => y(d.value))
  .attr("width", x.bandwidth())
  .attr("height", d => y(0) - y(d.value))
  .attr("fill", "#2563eb");

This works under a strict CSP because there’s no inline script, no eval-like behavior, and no remote fetch.

Loading remote chart data

The moment you do this:

const data = await d3.json("https://api.example.com/stats");

your CSP needs connect-src to allow that origin.

Example:

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

And the D3 code:

async function render() {
  const data = await d3.json("https://api.example.com/stats");

  const svg = d3.select("#chart")
    .append("svg")
    .attr("width", 640)
    .attr("height", 320);

  // render with data...
}

render().catch(err => {
  console.error("Chart failed to load", err);
});

If you’re using d3.csv, d3.tsv, or d3.text, same story: they all go through fetch/XHR behavior that CSP controls with connect-src.

Nonces for inline bootstrapping

Sometimes you want to inject a server-generated config object into the page. Fine. Use a nonce.

<script nonce="{{ .CSPNonce }}">
  window.chartConfig = {
    apiBase: "/api",
    reportId: "sales-2026"
  };
</script>

<script src="/assets/d3.v7.min.js"></script>
<script src="/assets/chart.js"></script>

Then your header:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

Server-side, generate a fresh nonce per response. Don’t reuse it across requests.

Example in Node/Express:

import crypto from "node:crypto";
import express from "express";

const app = express();

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.locals.cspNonce = nonce;

  res.setHeader(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
      "style-src 'self'",
      "img-src 'self' data:",
      "font-src 'self'",
      "connect-src 'self'",
      "base-uri 'self'",
      "form-action 'self'",
      "frame-ancestors 'none'",
      "object-src 'none'"
    ].join("; ")
  );

  next();
});

The style problem: D3 and style-src

D3 often sets presentation with attributes like .attr("fill", "steelblue"), which is great for CSP. SVG presentation attributes are not the same thing as inline <script>.

But teams also love this pattern:

selection.style("font-size", "12px");
selection.style("color", "red");

That creates inline styles on elements. Depending on your browser support target and policy strictness, this can push you toward loosening style-src. I try to avoid it. Prefer classes and stylesheet rules:

.bar {
  fill: #2563eb;
}

.axis text {
  font-size: 12px;
  fill: #334155;
}
svg.selectAll("rect")
  .data(data)
  .join("rect")
  .attr("class", "bar");

If your app absolutely depends on inline styles or a <style> block, you may end up with:

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

That’s common, but I consider it a compromise, not a goal.

Real-world header example

Here’s the real CSP header you provided from headertest.com:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-N2FkOGNkNzMtNmM2Yy00MWMzLTljNTktYThhYzA1MGU2NTU1' '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'

For a D3-powered analytics page, this is a very realistic shape:

  • script-src uses a nonce plus strict-dynamic, which I like.
  • connect-src is expanded for APIs, analytics, and WebSocket reporting.
  • frame-src is restricted to the consent provider.
  • object-src 'none', base-uri 'self', frame-ancestors 'none' are exactly what I want to see.

What I would question:

  • style-src 'unsafe-inline' is often there because consent managers and legacy UI snippets force it. If D3 is the only concern, you probably don’t need that.
  • img-src https: is broad. Convenient, yes. Tight, no. If charts only use local assets and data: URLs, narrow it.

A D3-specific version of that policy could be much smaller:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://api.headertest.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

That’s the pattern I’d start with before adding exceptions.

Common breakages and fixes

1. Chart data won’t load

Error usually points to connect-src.

Fix:

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

2. Inline bootstrap script blocked

You probably have something like:

<script>
  window.initialData = {...};
</script>

Fix it with a nonce:

script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
<script nonce="{{ .CSPNonce }}">
  window.initialData = {...};
</script>

3. Tooltip styling or SVG labels look broken

If D3 or your code is writing inline styles, your style-src may be too strict.

Better fix: move styles to CSS classes.

Fallback fix:

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

I’d only do that when I can’t refactor quickly.

4. External images inside charts fail

If labels or markers use external images:

svg.append("image")
  .attr("href", "https://cdn.example.com/logo.png");

Then add that origin to img-src.

A practical recommendation

For D3.js v7, I’d ship this unless the app proves it needs more:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  object-src 'none';

Then I’d expand only what breaks:

  • add API origins to connect-src
  • add image/CDN origins to img-src
  • keep third-party tags isolated and justified
  • avoid unsafe-inline and skip unsafe-eval entirely

That’s the nice part about D3: the library itself is not the thing fighting your CSP. Usually it’s the rest of the page. If you keep the page boring, D3 behaves just fine.