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:
style-src 'unsafe-inline'is doing too much heavy lifting.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-srcis 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-inlineinstyle-src - no
data:inimg-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.