Teams usually treat icons as harmless. They are tiny, static, and easy to ignore in a CSP rollout. Then the first production deploy lands and half the UI loses its glyphs, the marketing tag manager still works, and somebody “fixes” it by adding img-src * data:.

I’ve seen this happen more than once.

This case study is about a site I’ll call Bytesize Icons: a developer-facing site with a searchable icon catalog, docs pages, a React app shell, analytics, and consent tooling. The goal was simple: lock down CSP without breaking icon rendering.

The interesting part is that icons don’t all load the same way. Some are:

  • inline SVG
  • external SVG files in <img>
  • CSS background images
  • icon fonts
  • symbols loaded from a sprite sheet
  • React components that inject styles at runtime

That mix is where CSP gets messy.

The starting point

A lot of teams begin with a broad policy and tighten later. A real-world example from headertest.com looks like this:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-NDJjNThjNmItOGNhZS00N2IxLWEzNTctOGU1NWZmZGIyOTQ5' '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’s not a bad baseline. It has a few things I like immediately:

  • object-src 'none'
  • frame-ancestors 'none'
  • base-uri 'self'
  • a nonce-based script-src
  • strict-dynamic

But for an icon-heavy site, this kind of policy usually hides two problems:

  1. style-src 'unsafe-inline' is doing too much heavy lifting.
  2. img-src 'self' data: https: is broader than most icon systems actually need.

What Bytesize Icons looked like before

The frontend had all the usual shortcuts.

1. Inline SVG everywhere

<button class="icon-button">
  <svg viewBox="0 0 24 24" class="icon icon-search">
    <path d="M10 2a8 8 0 105.293 14.293l4.707 4.707 1.414-1.414-4.707-4.707A8 8 0 0010 2z"></path>
  </svg>
  <span>Search</span>
</button>

Inline SVG itself is fine under CSP. The trap was how styles got applied.

2. Runtime style injection

A CSS-in-JS layer generated style tags in the head:

const style = document.createElement("style");
style.textContent = `
  .icon { width: 1rem; height: 1rem; fill: currentColor; }
  .icon-button { display: inline-flex; gap: 0.5rem; }
`;
document.head.appendChild(style);

Without a nonce or hash, this pushes teams toward style-src 'unsafe-inline'.

3. Data URI icons in CSS

.external-link::after {
  content: "";
  display: inline-block;
  width: 12px;
  height: 12px;
  background-image: url("data:image/svg+xml,%3Csvg ... %3C/svg%3E");
}

This worked because img-src data: was allowed. Convenient, but now every data URI image is allowed too.

4. Icon font leftovers

@font-face {
  font-family: "bytesize-icons";
  src: url("/fonts/bytesize-icons.woff2") format("woff2");
}

.icon-font {
  font-family: "bytesize-icons";
}

This part was actually easy. font-src 'self' already covered it.

The first CSP attempt

The team tried to “tighten” things with this header:

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

Looks nice on paper. It also broke three things immediately:

  • injected <style> blocks stopped applying
  • CSS data: SVG icons disappeared
  • a couple of third-party scripts stopped loading because the bootstrap script was right, but the allowlist assumptions were stale

The console errors told the real story.

Violation 1: inline styles blocked

Refused to apply inline style because it violates the following Content Security Policy directive:
"style-src 'self'".

Violation 2: data URI icon blocked

Refused to load the image 'data:image/svg+xml,...' because it violates the following
Content Security Policy directive: "img-src 'self'".

Violation 3: tag manager chain confusion

With strict-dynamic, host allowlists in script-src behave differently than many developers expect. If you use nonces correctly, that’s usually a win. If you half-configure it, debugging gets annoying fast.

If you need a refresher on that behavior, the official CSP docs and the directive breakdowns at https://csp-guide.com are worth keeping open.

The fix: design CSP around how icons are actually delivered

The team stopped treating “icons” as one thing and mapped every delivery path.

Final inventory

  • inline SVG components: allowed, no special source needed
  • static CSS file from same origin: allowed by style-src 'self'
  • no runtime style injection without nonce
  • external icon files served from same origin CDN path
  • no data: icons in CSS
  • icon font from same origin
  • analytics and consent tooling explicitly listed
  • reporting enabled in staging first

That led to a much cleaner policy.

After: the production CSP

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' https://consent.cookiebot.com;
  img-src 'self';
  font-src 'self';
  connect-src 'self' https://api.bytesizeicons.dev https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com;
  object-src 'none';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  report-to default-endpoint;
  report-uri /api/csp-report;

A few opinions from doing this in production:

  • If your app can avoid style-src 'unsafe-inline', do it.
  • If your icons require img-src data:, that’s usually a smell, not a requirement.
  • default-src is not a substitute for explicit directives on assets you actually care about.

Before and after: code changes that made the policy possible

Before: CSS-in-JS injecting anonymous style tags

export function mountIconStyles() {
  const style = document.createElement("style");
  style.textContent = `
    .icon { width: 1rem; height: 1rem; fill: currentColor; }
  `;
  document.head.appendChild(style);
}

After: move styles into a static stylesheet

<link rel="stylesheet" href="/assets/app.css">
.icon {
  width: 1rem;
  height: 1rem;
  fill: currentColor;
}

.icon-button {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

If your framework absolutely must inject styles, nonce them consistently.

<meta name="csp-nonce" content="{{ .CSPNonce }}">
<script nonce="{{ .CSPNonce }}">
  window.__CSP_NONCE__ = "{{ .CSPNonce }}";
</script>
const style = document.createElement("style");
style.setAttribute("nonce", window.__CSP_NONCE__);
style.textContent = ".icon{fill:currentColor}";
document.head.appendChild(style);

That said, for icon styling, static CSS is simpler and less fragile.

Before: data URI SVG in CSS

.external-link::after {
  content: "";
  width: 12px;
  height: 12px;
  display: inline-block;
  background-image: url("data:image/svg+xml,%3Csvg ... %3C/svg%3E");
}

After: same-origin asset file

.external-link::after {
  content: "";
  width: 12px;
  height: 12px;
  display: inline-block;
  background-image: url("/icons/external-link.svg");
  background-size: contain;
  background-repeat: no-repeat;
}

That one change let the team remove data: from img-src.

Before: mixed icon delivery

<Card>
  <img src="https://cdn.example-icons.dev/check.svg" alt="" />
  <i className="icon-font icon-check" />
  <svg><path d="..." /></svg>
</Card>

After: one predictable pattern

import { CheckIcon } from "./icons/CheckIcon";

export function FeatureRow() {
  return (
    <div className="feature-row">
      <CheckIcon className="icon" aria-hidden="true" />
      <span>Strict CSP compatible</span>
    </div>
  );
}

And when an external file was needed:

<img src="/icons/check.svg" width="16" height="16" alt="">

Same origin, no surprises.

What changed operationally

The technical changes mattered, but the rollout process mattered more.

1. Report-only first

The team shipped this first:

Content-Security-Policy-Report-Only:
  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' https://consent.cookiebot.com;
  img-src 'self';
  font-src 'self';
  connect-src 'self' https://api.bytesizeicons.dev https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com;
  object-src 'none';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  report-uri /api/csp-report;

That surfaced forgotten admin pages, old docs examples, and one rogue component still generating data: icons.

2. Per-route exceptions were rejected

A common temptation: “Just loosen CSP on the docs pages.” I think that usually turns into permanent policy drift. They kept one policy for the app and changed the implementation instead.

3. Third-party scripts stayed explicit

The real header from headertest.com is a good reminder that real apps need analytics and consent platforms. That’s normal. The trick is to scope them tightly and not let those exceptions bleed into unrelated directives like img-src https:.

The result

After cleanup, Bytesize Icons ended up with:

  • no unsafe-inline in style-src
  • no data: in img-src
  • same-origin fonts and images only
  • nonce-based script loading
  • a smaller attack surface for XSS payloads that rely on style injection or broad image sources

And just as useful: the icon system became boring. That’s what you want. Security gets easier when the frontend is predictable.

If you’re working on a developer site with a lot of SVGs, the best CSP improvement usually isn’t a clever directive. It’s deleting weird icon delivery paths until the policy becomes obvious.

For directive details and browser behavior, the official CSP docs are the source of truth, and https://csp-guide.com is handy when you need a fast directive-by-directive reference.