Carbon gives teams a solid design system, but it does not magically solve CSP. I’ve seen plenty of Carbon-based apps ship with a polished UI and a deeply unserious security header: default-src *, script-src 'unsafe-inline', or no CSP at all because “the charts broke.”
That tradeoff usually happens when a team mixes Carbon with analytics, consent tooling, a React build pipeline, and a few “temporary” inline scripts that somehow survive for two years.
Here’s a real-world style case study of tightening CSP on a Carbon-based IBM app without wrecking the frontend.
The setup
The app looked pretty normal:
- React app built on Carbon
- IBM-hosted internal product dashboard
- Google Tag Manager
- Google Analytics
- Cookie consent banner
- WebSocket connection for live updates
- A couple of inline hydration/config scripts in the server-rendered shell
The original CSP had grown by exception:
Content-Security-Policy:
default-src * data: blob: 'unsafe-inline' 'unsafe-eval';
script-src * data: blob: 'unsafe-inline' 'unsafe-eval';
style-src * data: 'unsafe-inline';
img-src * data: blob:;
connect-src * wss:;
frame-src *;
Yes, technically it “worked.” It also removed most of the point of CSP.
The team’s pain points were familiar:
- Carbon components rendered fine, but some styles were injected dynamically.
- GTM wanted script access.
- Cookie consent loaded assets from a different domain.
- A tiny inline bootstrapping script blocked any move away from
'unsafe-inline'. - Developers were scared to tighten anything because the app had become dependency-driven.
That fear is why weak CSPs linger.
What we actually wanted
The target was not “perfectly pure CSP with zero third parties.” That’s a nice conference slide, not always a practical product constraint.
The goal was:
- kill broad wildcards
- remove
'unsafe-eval' - remove
'unsafe-inline'fromscript-src - restrict third-party origins to the ones the app really used
- support Carbon and React without weird hacks
- make future reviews easier
A good reference point for a modern production CSP is the one exposed by HeaderTest. Their real header is:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-NWJhZDBlNDAtY2VmMS00MjU3LWJmNGEtNzc4ZDE1NTlhY2Zl' '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'
That header isn’t minimal, but it is disciplined. That’s the difference that matters in production.
Before: where the Carbon app went wrong
The biggest issue was that the app treated CSP as a compatibility layer instead of a security control.
1. Inline boot script
The server rendered this into index.html:
<script>
window.__APP_CONFIG__ = {
apiBase: "https://api.internal.example",
env: "prod"
};
</script>
That forced 'unsafe-inline' unless we changed the pattern.
2. Old build assumptions
A legacy dependency expected eval-like behavior in development and accidentally influenced production config. That kept 'unsafe-eval' around even though the shipped app didn’t need it.
3. Third-party sprawl
The team had whitelisted entire schemes and broad hosts because nobody knew which requests were actually required:
script-src * 'unsafe-inline' 'unsafe-eval';
connect-src * wss:;
img-src * data:;
That is basically “please execute whatever lands here.”
4. Style policy confusion
Carbon itself isn’t the problem. The problem is how apps around Carbon inject styles, especially with CSS-in-JS, consent managers, or tag managers. Teams often slap 'unsafe-inline' into style-src and move on.
Sometimes that’s a practical compromise. Sometimes it’s just laziness.
After: a workable production CSP
We rebuilt the policy around actual app behavior.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.internal.example wss://stream.internal.example 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';
report-uri /csp-report;
A few choices here were deliberate.
script-src uses nonce + strict-dynamic
This is the big one. If you’re still allowing inline scripts wholesale in a React/Carbon app, you’re leaving an obvious gap open.
We changed the inline config script to use a per-request nonce:
<script nonce="{{nonce}}">
window.__APP_CONFIG__ = {
apiBase: "https://api.internal.example",
env: "prod"
};
</script>
And the header included the same nonce:
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com;
If you need a refresher on how directives like script-src, strict-dynamic, and frame-ancestors behave, csp-guide.com is a good reference.
We removed unsafe-eval
This broke nothing in production.
That happens a lot. Teams keep 'unsafe-eval' because some old webpack-dev-server era issue scared them once. Then nobody checks whether it still matters in the built app.
Check the real production bundle, not your memory of a dev setup from three years ago.
We kept style-src 'unsafe-inline' for now
I’m not going to pretend every production app can remove it overnight.
If you have consent tooling or runtime-injected styles, style-src 'unsafe-inline' may still be the least painful compromise while you clean up the stack. The key is not to let that compromise infect script-src. Inline styles are bad; inline scripts are much worse.
For this app, keeping:
style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com;
was acceptable during the first hardening pass.
We scoped connect-src to real endpoints
The app only needed:
- its own API
- one WebSocket endpoint
- analytics-related endpoints
- consent-related endpoints
So we wrote exactly that:
connect-src 'self' https://api.internal.example wss://stream.internal.example https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
That made debugging easier too. When something new broke, it was visible immediately instead of silently allowed.
The app changes that made this possible
CSP cleanup is rarely just a header edit. We had to fix code.
Before: inline event handlers and config
<button onclick="openSupportPanel()">Help</button>
<script>
window.__APP_CONFIG__ = { theme: "g100" };
</script>
After: proper JS binding + nonced bootstrap
<button id="help-button">Help</button>
<script nonce="{{nonce}}">
window.__APP_CONFIG__ = { theme: "g100" };
</script>
<script nonce="{{nonce}}" src="/static/app.js"></script>
document
.getElementById("help-button")
.addEventListener("click", openSupportPanel);
That one cleanup removes a lot of pressure to keep weak script rules.
Example: Express nonce wiring
Here’s the server pattern we used:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString("base64");
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
`script-src 'self' 'nonce-${res.locals.nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com`,
"style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.internal.example wss://stream.internal.example 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'"
].join("; ")
);
next();
});
Then in the template:
<script nonce="{{nonce}}">
window.__APP_CONFIG__ = {{ configJson }};
</script>
That’s the kind of change that actually survives contact with a real app.
Results
After rollout:
- no more wildcard CSP
- no more
'unsafe-eval' - no more
'unsafe-inline'inscript-src - analytics and consent flows still worked
- Carbon UI behaved normally
- violations became actionable instead of meaningless noise
The biggest operational improvement was psychological: the team stopped treating CSP as mysterious. Once the policy matched actual dependencies, debugging got simpler.
What I’d do next
The next hardening pass would focus on style-src.
If the app can move away from runtime inline styles or isolate which library is forcing them, I’d push to remove 'unsafe-inline' there too. I’d also consider report-to alongside or instead of report-uri, depending on browser support needs and reporting infrastructure.
I’d keep frame-ancestors 'none' unless the product genuinely needs embedding. Same for object-src 'none'; there’s almost never a good reason to loosen it in a modern Carbon app.
The practical takeaway for Carbon teams
Carbon is not the blocker. Usually the blockers are:
- old frontend habits
- inline scripts in templates
- overbroad third-party allowances
- fear of breaking analytics
Start with real behavior, not a copy-pasted CSP. Use nonces for unavoidable inline bootstrap code. Be strict on scripts first. Accept that styles may take one extra cleanup cycle.
That’s how you get from a decorative header to a security control that actually earns its place.