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-inlinefor scripts
What D3 v7 needs from CSP
For a typical chart page, D3 itself only needs a few things:
script-srcto load D3 and your app scriptconnect-srcif you fetch CSV, JSON, TSV, or API dataimg-srcif you embed images in SVG or HTML tooltipsstyle-srcif you use inline styles or<style>tagsfont-srcif your chart UI loads webfonts
D3 does not require:
unsafe-evalwasm-unsafe-evalobject-srcanything 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-srcshould use nonces if you have any inline bootstrap script at all.strict-dynamicis 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-srcuses a nonce plusstrict-dynamic, which I like.connect-srcis expanded for APIs, analytics, and WebSocket reporting.frame-srcis 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 anddata: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-inlineand skipunsafe-evalentirely
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.