Variable fonts look simple from the design side: one font file, multiple weights, less asset sprawl. Then CSP gets involved and suddenly your typography falls back to Arial in production while everything worked fine locally.

I’ve seen this happen a lot. Teams lock down script-src, spend hours on nonces, and then leave fonts as an afterthought. With variable fonts, that gets messy fast because the loading path usually crosses CSS, font files, CDNs, and sometimes inline styles from third-party tools.

Here’s a real-world case study based on the kind of policy you actually see in production.

The starting point

This is the real CSP header currently seen on headertest.com:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-MWRiMmU0ZWMtYmZmYi00ZjI1LThiYWItYzdlYTgzN2MxNzUw' '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'

At first glance, font-src 'self' looks fine. If your variable font is hosted locally, that should work.

But this is where reality bites: variable fonts often fail because font-src is only one piece of the chain.

The bug report

The frontend team shipped a variable font setup like this:

@font-face {
  font-family: 'InterVariable';
  src: url('/assets/fonts/InterVariable.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

:root {
  --font-body: 'InterVariable', system-ui, sans-serif;
}

body {
  font-family: var(--font-body);
}

Looks normal. Font file is same-origin. font-src 'self' should allow it.

But production users saw fallback fonts on the first load, and CSP reports started showing blocked resources after a redesign introduced externally hosted CSS for font loading during an A/B test.

The test variant used this:

<link rel="stylesheet" href="https://cdn.example-fonts.test/inter.css">

And that CSS contained:

@font-face {
  font-family: 'InterVariable';
  src: url('https://cdn.example-fonts.test/InterVariable.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

Now the existing CSP breaks in two places:

  1. style-src does not allow https://cdn.example-fonts.test
  2. font-src only allows 'self', so the actual font file is blocked too

That’s the classic mistake. People add the font CDN to font-src and forget the stylesheet origin. Or they allow the stylesheet but not the font file. With fonts, you usually need both.

What the browser actually enforces

For variable fonts, the browser evaluates at least these directives:

  • style-src for external stylesheets or inline styles containing @font-face
  • font-src for the .woff2 file itself
  • sometimes default-src as fallback if font-src is missing, which I don’t recommend relying on

If you want the official CSP behavior details, the directive references at https://developer.mozilla.org/ are the source I trust, and https://csp-guide.com is useful when you want a quicker mental model.

Before: the fragile policy

Here’s the simplified “before” setup that caused the issue:

Content-Security-Policy:
  default-src 'self';
  style-src 'self' 'unsafe-inline';
  font-src 'self';
  img-src 'self' data: https:;
  script-src 'self' 'nonce-abc123' 'strict-dynamic';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

And the app code drifted into this:

<link rel="stylesheet" href="https://cdn.example-fonts.test/inter.css">
@font-face {
  font-family: 'InterVariable';
  src: url('https://cdn.example-fonts.test/InterVariable.woff2') format('woff2-variations');
  font-weight: 100 900;
}

Result

  • stylesheet blocked by style-src
  • font blocked by font-src
  • fallback font renders
  • design team files a “font bug”
  • security team gets blamed, which is annoying because the policy did exactly what it was supposed to do

The better fix

My preference is simple: self-host variable fonts unless you have a strong reason not to.

That keeps CSP tighter and removes a whole class of supply-chain headaches. One variable font file served from your own origin is easier to cache, easier to audit, and easier to explain.

After: self-hosted variable font

@font-face {
  font-family: 'InterVariable';
  src: url('/assets/fonts/InterVariable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

body {
  font-family: 'InterVariable', system-ui, sans-serif;
}

And the CSP stays tight:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123' 'strict-dynamic';
  style-src 'self';
  font-src 'self';
  img-src 'self' data: https:;
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

This is the clean version. No extra font host, no extra stylesheet origin, no need to punch more holes in the policy.

When you really do need a font CDN

Sometimes marketing, localization, or a design system vendor forces the issue. Fine. If the font CSS and font files come from a separate origin, allow both explicitly.

After: external stylesheet and external font file allowed

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123' 'strict-dynamic';
  style-src 'self' https://cdn.example-fonts.test;
  font-src 'self' https://cdn.example-fonts.test;
  img-src 'self' data: https:;
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

That’s the minimum viable change.

If the stylesheet is injected inline by application code or a third-party consent tool, that’s another story. Then style-src becomes the weak point.

The awkward part: inline styles

Look back at the headertest.com policy:

style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;

I get why teams do this. Third-party widgets often push inline styles, and removing 'unsafe-inline' can be painful.

But if your variable font setup depends on inline @font-face rules or dynamic <style> tags, 'unsafe-inline' makes enforcement less meaningful. The browser will allow any inline CSS, not just your font declarations.

If you can, move font definitions into static CSS files and get rid of 'unsafe-inline'. If you can’t do it immediately, at least don’t expand font-src broadly at the same time. A weak style-src plus a permissive font-src is how accidental data exposure and weird rendering dependencies creep in.

A practical debugging workflow

When variable fonts fail under CSP, I check these in order:

1. Is the stylesheet blocked?

Look for console errors like:

Refused to load the stylesheet 'https://cdn.example-fonts.test/inter.css'
because it violates the following Content Security Policy directive:
"style-src 'self'".

If yes, fix style-src.

2. Is the font file blocked?

Then you may see:

Refused to load the font 'https://cdn.example-fonts.test/InterVariable.woff2'
because it violates the following Content Security Policy directive:
"font-src 'self'".

If yes, fix font-src.

3. Is the MIME type or CORS wrong instead?

Not every font failure is CSP. I’ve seen teams spend an afternoon editing headers when the actual issue was a bad Content-Type or missing cross-origin font headers.

For cross-origin fonts, make sure the response is sane:

Content-Type: font/woff2
Access-Control-Allow-Origin: https://your-site.example

Or * if that’s acceptable for your setup.

4. Test with Report-Only first

For policy changes on production apps, I usually stage them with Content-Security-Policy-Report-Only first:

Content-Security-Policy-Report-Only:
  default-src 'self';
  style-src 'self' https://cdn.example-fonts.test;
  font-src 'self' https://cdn.example-fonts.test;
  report-to csp-endpoint;

That gives you signal without breaking typography for real users.

A hardened version for the original real-world policy

If I were tuning the headertest.com policy specifically for self-hosted variable fonts, I’d keep font-src 'self' and focus on reducing style risk over time.

Something like this as a target state:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 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';

The big change is removing 'unsafe-inline' from style-src, assuming the app can support it. That’s not specifically a variable-font requirement, but it matters because font loading often gets bundled into CSS exceptions people stop questioning.

What I’d tell a team shipping variable fonts tomorrow

  • Self-host the variable font if you can
  • Keep font-src 'self' if the file is local
  • If you load CSS from a font CDN, allow that origin in style-src
  • If the font file also comes from that CDN, allow it in font-src
  • Don’t use broad wildcards just to make typography work
  • Don’t rely on 'unsafe-inline' forever
  • Roll out CSP changes in Report-Only before enforcement

Variable fonts are not the hard part. The hard part is remembering that CSP follows the entire resource chain, not just the final .woff2 request. That’s where most breakage comes from, and once you see it, the fixes are pretty boring. Which is exactly what you want from security.