Web fonts are one of those things teams barely think about until the site starts flashing invisible text, Lighthouse complains, or CSP suddenly blocks production traffic.

I’ve seen this happen a lot: someone tightens Content Security Policy, feels good about shipping a safer header, and then fonts start failing in subtle ways. Not always completely broken. Sometimes they just get slower. And slow fonts are nasty because they hurt rendering, CLS, and perceived quality without looking like an obvious outage.

If you’re working on a developer-facing site, docs site, SaaS app, or marketing pages, here are the common CSP mistakes that hurt font performance and the fixes that actually work.

1. Using default-src and assuming fonts are covered

A lot of CSP bugs start with this assumption:

Content-Security-Policy: default-src 'self';

Technically, yes, default-src applies as a fallback. But relying on it for fonts is lazy and usually causes confusion later when you move fonts to a CDN or add a third-party stylesheet.

Be explicit:

Content-Security-Policy: default-src 'self'; font-src 'self';

That tells future-you and your teammates exactly what is allowed.

A real example from HeaderTest keeps this clean:

font-src 'self';

That’s a good default when you self-host fonts. If you want stricter, more maintainable CSP, explicit directives are easier to reason about than “fallback magic.”

If you want a refresher on directive behavior, csp-guide.com is a solid reference.

2. Hosting fonts on a CDN but forgetting font-src

This is probably the most common production mistake.

You move .woff2 files to a CDN for caching and edge delivery:

@font-face {
  font-family: Inter;
  src: url("https://cdn.example.com/fonts/inter-var.woff2") format("woff2");
  font-display: swap;
}

But your CSP still says:

Content-Security-Policy: default-src 'self'; font-src 'self';

Result: blocked font requests.

The obvious fix is to allow the CDN:

Content-Security-Policy:
  default-src 'self';
  font-src 'self' https://cdn.example.com;

Simple enough. The less obvious part is that teams sometimes allow the stylesheet host but not the font host. Those are often different.

For example, with Google Fonts:

  • CSS comes from fonts.googleapis.com
  • font binaries come from fonts.gstatic.com

So this is broken:

style-src 'self' https://fonts.googleapis.com;
font-src 'self';

You need both:

style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;

3. Allowing the font file but blocking the stylesheet

This one is the inverse of the previous mistake.

You add font-src correctly, but the CSS that declares the @font-face is still blocked:

Content-Security-Policy:
  default-src 'self';
  font-src 'self' https://fonts.gstatic.com;
  style-src 'self';

If you load:

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">

the browser can’t even fetch the stylesheet, so the font files never get a chance.

Fix it by allowing both sources:

Content-Security-Policy:
  default-src 'self';
  style-src 'self' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;

This is one reason I usually recommend self-hosting fonts for performance-sensitive sites. Fewer third-party dependencies, fewer CSP exceptions, fewer surprises.

4. Preloading fonts without matching CORS behavior

This one hurts performance more than outright functionality.

A lot of teams preload fonts like this:

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2">

Looks fine. But fonts are fetched in CORS mode, even when they’re same-origin in many setups. If the preload request doesn’t match the eventual font fetch, the browser may download the file twice.

The fix is usually to include crossorigin:

<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin>

And if the font is on another origin, the server also needs the right CORS header:

Access-Control-Allow-Origin: https://www.example.com

or, if you really mean it:

Access-Control-Allow-Origin: *

CSP doesn’t replace CORS here. I still see people treat font-src as if it grants cross-origin access by itself. It does not. CSP says whether the browser is allowed to request the resource. CORS says whether the response can be used.

If your preload is ignored or duplicated, check both.

5. Self-hosting fonts but forgetting the cache strategy

This isn’t a pure CSP bug, but it shows up in the same conversations because teams lock down font-src 'self' and assume they’re done.

If you self-host fonts for security and privacy, good. But if you serve them with weak caching, you just traded one problem for another.

A decent setup looks like this:

Cache-Control: public, max-age=31536000, immutable
Content-Type: font/woff2

And use versioned filenames:

/inter-var.8f3a1c.woff2

That gives you the nice combination:

  • strict font-src 'self'
  • fast repeat loads
  • no third-party DNS/TLS/font fetch overhead

This is one of the few areas where security and performance genuinely align.

6. Keeping unsafe-inline in style-src because of fonts

I see this rationalization a lot: “We need inline styles for font loading.”

Usually, you don’t.

Here’s the kind of CSP I often find:

style-src 'self' 'unsafe-inline';

And then nobody remembers why it’s there.

If your only reason is a tiny inline @font-face block or some font-loading helper styles, move them into a stylesheet you control. If you truly need inline styles, use a nonce or hash where possible rather than leaving unsafe-inline around forever.

The real HeaderTest policy includes:

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

That may be justified by consent tooling, but for your own font setup, don’t use web fonts as the excuse for a weaker style policy.

7. Loading too many font variants and blaming CSP

Not every font performance issue is caused by CSP, but CSP often gets blamed because it’s the most visible change.

If you load this:

@font-face { font-family: Inter; font-weight: 400; src: url("/fonts/inter-400.woff2") format("woff2"); }
@font-face { font-family: Inter; font-weight: 500; src: url("/fonts/inter-500.woff2") format("woff2"); }
@font-face { font-family: Inter; font-weight: 600; src: url("/fonts/inter-600.woff2") format("woff2"); }
@font-face { font-family: Inter; font-weight: 700; src: url("/fonts/inter-700.woff2") format("woff2"); }
@font-face { font-family: Inter; font-weight: 800; src: url("/fonts/inter-800.woff2") format("woff2"); }

you may simply be over-fetching. CSP didn’t make this slow. Your font strategy did.

A variable font can reduce requests dramatically:

@font-face {
  font-family: Inter;
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/fonts/inter-var.woff2") format("woff2");
}

That gives you better performance and keeps CSP easier because you’re managing fewer assets.

8. Using font-src * to “just make it work”

Please don’t do this:

font-src *

Yes, it will probably stop the errors. It also removes most of the value of the directive.

Fonts are a great candidate for a tight policy because the valid origins are usually few and stable. Most sites can use one of these:

font-src 'self';

or:

font-src 'self' https://cdn.example.com;

That’s enough for most real deployments.

Broad allowlists are usually a sign that nobody mapped the actual resource flow.

9. Forgetting font-display

Again, not strictly CSP, but it matters because teams often focus so hard on unblocking fonts that they forget rendering behavior.

If your @font-face doesn’t define font-display, you’re leaving UX to browser defaults.

Use this:

@font-face {
  font-family: Inter;
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-display: swap;
}

Or, if you’re being more aggressive about layout stability, test optional.

A perfectly valid CSP with a bad font rendering strategy still feels slow to users.

10. Not testing the final header, only the code

I’ve watched teams review pristine config in a PR and still ship a broken header because of CDN rewrites, framework middleware order, duplicate headers, or environment-specific differences.

Always test the actual response header in production or staging.

For reference, this is the live CSP HeaderTest sends today:

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

The key bit for fonts is still the boring one:

font-src 'self';

That’s usually what “good” looks like when you self-host.

A practical baseline

If you want a sane, fast default for self-hosted fonts, start here:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

And pair it with:

<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin>
@font-face {
  font-family: Inter;
  font-style: normal;
  font-weight: 100 900;
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-display: swap;
}

That setup is fast, boring, and secure. Which is exactly what you want from font delivery.

The biggest mistake I see is treating CSP as a box-checking header instead of part of the resource loading pipeline. Fonts sit right at the intersection of security, caching, CORS, and rendering. If you only fix one of those, you’ll still end up with a site that feels slower than it should.