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:

  1. Carbon components rendered fine, but some styles were injected dynamically.
  2. GTM wanted script access.
  3. Cookie consent loaded assets from a different domain.
  4. A tiny inline bootstrapping script blocked any move away from 'unsafe-inline'.
  5. 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' from script-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' in script-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.