A lot of CSP breakage looks random until you hit icons.
Text loads. JavaScript loads. Layout mostly works. Then half the UI shows empty squares, missing chevrons, or buttons with no visual affordance at all. I’ve seen teams burn hours blaming CSS pipelines when the real problem was much simpler: the icon delivery method didn’t match the site’s Content Security Policy.
This case study is about that exact problem with Evergreen icons on a production-style setup.
The setup
The site is a typical modern marketing/app hybrid. It uses a fairly strict CSP and some third-party tooling. A real header from headertest.com official docs and examples discussion territory looks like this:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-NTlkZTA2M2QtYjI5MC00MzMzLWI4ZDMtYmI3YWNlZjgzYzlj' '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 policy is not wild. It’s actually pretty sane:
default-src 'self'- nonced scripts with
'strict-dynamic' - no plugins via
object-src 'none' - no embedding via
frame-ancestors 'none' - fonts only from self via
font-src 'self'
And that last line is where icon systems often go sideways.
The problem
A team added Evergreen-style UI icons through an icon font package. The package looked harmless enough:
<link rel="stylesheet" href="/assets/evergreen-icons.css">
@font-face {
font-family: 'EvergreenIcons';
src:
url('https://cdn.example-icons.com/evergreen-icons.woff2') format('woff2'),
url('https://cdn.example-icons.com/evergreen-icons.woff') format('woff');
font-display: block;
}
.icon {
font-family: 'EvergreenIcons';
speak: none;
font-style: normal;
font-weight: normal;
line-height: 1;
}
.icon-chevron-right:before {
content: '\e901';
}
On localhost, everything worked.
In production, icons disappeared.
Chrome DevTools told the real story:
Refused to load the font 'https://cdn.example-icons.com/evergreen-icons.woff2'
because it violates the following Content Security Policy directive:
"font-src 'self'".
That was the first failure.
Then the team tried a quick fix: replace the icon font with CSS background-image SVGs from a CDN.
.icon-external-link {
background-image: url("https://cdn.example-icons.com/external-link.svg");
}
That sort of worked, but only because img-src 'self' data: https: already allowed remote HTTPS images. Different delivery path, different CSP directive.
But it created a messy split:
- some icons came from a font, blocked by
font-src - some came from remote SVG images, allowed by
img-src - some inline icons were copied directly into templates
That inconsistency is how frontends become impossible to reason about.
Before: the fragile version
Here’s a simplified “before” example.
HTML
<button class="btn">
Save
<span class="icon icon-chevron-right" aria-hidden="true"></span>
</button>
<a class="docs-link" href="/docs">
Docs
<span class="icon-external-link" aria-hidden="true"></span>
</a>
CSS
@font-face {
font-family: 'EvergreenIcons';
src: url('https://cdn.example-icons.com/evergreen-icons.woff2') format('woff2');
}
.icon {
font-family: 'EvergreenIcons';
}
.icon-chevron-right:before {
content: '\e901';
}
.icon-external-link {
display: inline-block;
width: 1rem;
height: 1rem;
background: center / contain no-repeat
url("https://cdn.example-icons.com/external-link.svg");
}
CSP
Content-Security-Policy:
default-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self';
img-src 'self' data: https:;
object-src 'none';
base-uri 'self';
What happened:
- The icon font was blocked by
font-src 'self'. - The remote SVG background image loaded because
img-srcallowedhttps:. - Some icons appeared, others didn’t.
- The UI looked randomly broken depending on icon type.
That’s the worst kind of bug: partial failure.
What we changed
I usually push teams toward one of two patterns:
- Self-host icon fonts if you’re already committed to a font-based icon system.
- Use inline SVG or local SVG sprites if you want the cleanest long-term CSP story.
For this site, we chose option 2 for most UI icons and kept a small self-hosted font fallback only during migration.
Why? Because SVG is easier to audit, easier to style, and less weird from a CSP perspective. Fonts for icons are legacy baggage more often than not.
After: stable and CSP-friendly
Option A: inline SVG components
This is the cleanest version.
<button class="btn">
Save
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M6 3l5 5-5 5-1.4-1.4L8.2 8 4.6 4.4 6 3z" fill="currentColor"></path>
</svg>
</button>
CSS
.icon {
width: 1rem;
height: 1rem;
display: inline-block;
vertical-align: text-bottom;
}
No font-src dependency. No remote asset fetch. No weird pseudo-element content codes.
Option B: self-hosted SVG sprite
If you want reusable symbols:
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-chevron-right" viewBox="0 0 16 16">
<path d="M6 3l5 5-5 5-1.4-1.4L8.2 8 4.6 4.4 6 3z"></path>
</symbol>
</svg>
<button class="btn">
Save
<svg class="icon" aria-hidden="true" focusable="false">
<use href="#icon-chevron-right"></use>
</svg>
</button>
Also CSP-friendly because there’s no network request.
Option C: self-hosted icon font during migration
If you can’t replace icon fonts immediately, at least make them match the policy:
@font-face {
font-family: 'EvergreenIcons';
src:
url('/fonts/evergreen-icons.woff2') format('woff2'),
url('/fonts/evergreen-icons.woff') format('woff');
font-display: block;
}
.icon {
font-family: 'EvergreenIcons';
}
With the original real-world policy, this works because:
font-src 'self';
No CSP change required.
The final policy decision
We did not loosen font-src to allow a third-party icon CDN.
That was deliberate.
It’s tempting to patch the issue like this:
font-src 'self' https://cdn.example-icons.com;
That would make the icons appear. It would also expand the trust boundary for no good reason.
I’d rather self-host static assets like fonts and icons almost every time. They change rarely, cache well, and don’t need runtime trust in an external origin.
The production CSP stayed close to the original:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-NTlkZTA2M2QtYjI5MC00MzMzLWI4ZDMtYmI3YWNlZjgzYzlj' '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 gave us a simple rule:
- icons as inline SVG: no CSP impact
- icons as local sprite: no CSP impact
- any remaining font icons: must be self-hosted
The debugging checklist that actually helped
When icons break under CSP, I go through these in order:
1. Identify how the icon is delivered
Icons can come from:
@font-faceweb fontsbackground-image: url(...)- inline
<svg> - external SVG files in
<img> - sprite sheets with
<use> - pseudo-elements with generated content
Each one maps to different CSP behavior.
2. Match delivery to the directive
Roughly:
- icon fonts ->
font-src - SVG/image URLs ->
img-src - inline styles affecting icons ->
style-src - external sprite fetches can get weird depending on implementation, so I avoid them unless I’ve tested the browser matrix carefully
If you need a refresher on directive behavior, the official CSP reference is at MDN Content Security Policy documentation, and I also like the directive breakdowns on CSP Guide.
3. Don’t “fix” the wrong directive
I’ve watched people loosen img-src while the actual block was font-src. Easy mistake, especially with SVG involved.
4. Prefer self-hosting over expanding trust
If the asset is static and belongs to your app, self-host it. That applies doubly to icons.
The result
After the cleanup:
- no more missing Evergreen icons in production
- no extra third-party origin added to
font-src - simpler rendering path
- easier accessibility work because SVGs can be labeled or hidden correctly
- fewer “works on my machine” differences between local and prod
The biggest win wasn’t just that the icons came back. It was that the CSP became easier to reason about.
That’s usually the real goal with CSP. Not maximum strictness for bragging rights. Just a policy that matches how your frontend actually loads things.
If your Evergreen icons are still font-based, my blunt advice is this: self-host them now, then plan a move to SVG. It’ll save you from a lot of avoidable CSP nonsense.