Self-hosting Google Fonts is supposed to simplify CSP. No fonts.googleapis.com, no fonts.gstatic.com, fewer third parties, cleaner policy.
That’s the theory.
In practice, I keep seeing teams self-host fonts and still break rendering, keep unsafe CSP rules they no longer need, or ship policies that are way broader than necessary. The annoying part is that the app usually “works” until someone tightens CSP in production and suddenly every heading falls back to Arial.
Here are the mistakes I see most often, and how I’d fix them.
Mistake 1: Keeping Google domains in CSP after self-hosting
If you self-host the CSS and the font files, you usually do not need Google font domains in CSP anymore.
I’ve seen setups like this:
Content-Security-Policy:
default-src 'self';
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
That policy is fine for Google-hosted fonts, not self-hosted ones.
If your font CSS is served from your own origin and the .woff2 files are also served from your own origin, the CSP can usually be:
Content-Security-Policy:
default-src 'self';
style-src 'self';
font-src 'self';
That’s one of the biggest wins from self-hosting: you can stop punching holes for third-party font infrastructure.
The real-world header from headertest.com already does this correctly for fonts:
font-src 'self';
That’s exactly what I want to see for self-hosted fonts.
If you want a deeper directive reference, csp-guide.com has a solid breakdown of font-src.
Mistake 2: Forgetting that the font CSS is still a stylesheet
People focus on font-src and forget that the @font-face rules usually live in a CSS file, which is controlled by style-src.
Typical broken setup:
Content-Security-Policy:
default-src 'self';
font-src 'self';
And then:
<link rel="stylesheet" href="/assets/fonts/inter.css">
This fails because the stylesheet load is governed by style-src, not font-src.
Fix it like this:
Content-Security-Policy:
default-src 'self';
style-src 'self';
font-src 'self';
The mental model is simple:
style-srccontrols the CSS filefont-srccontrols the font binary like.woff2
You need both.
Mistake 3: Self-hosting the files but leaving inline style exceptions around forever
A lot of teams self-host fonts as part of a broader cleanup, but never revisit style-src. They remove external font domains and still leave 'unsafe-inline' in place because “styles need it”.
Usually they don’t.
The headertest.com header includes:
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
For that site, 'unsafe-inline' may be there for other reasons, not fonts specifically. But this is where developers get confused: self-hosted fonts do not require inline styles.
If your old font integration looked like this:
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
}
</style>
you may have added 'unsafe-inline' just to make that work.
That’s fixable. Move the font rules into a dedicated stylesheet:
/* /assets/fonts/inter.css */
@font-face {
font-family: 'Inter';
src: url('/assets/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
body {
font-family: 'Inter', sans-serif;
}
Then load it normally:
<link rel="stylesheet" href="/assets/fonts/inter.css">
And tighten CSP:
Content-Security-Policy:
default-src 'self';
style-src 'self';
font-src 'self';
If you still need inline styles for some unrelated reason, fine. But don’t blame fonts for it. Fonts are usually the easiest thing to get off 'unsafe-inline'.
Official docs for style-src are here: MDN style-src.
Mistake 4: Using the wrong paths after moving files locally
This one is less about CSP itself and more about how CSP makes bad paths obvious.
You copy the CSS from Google Fonts, download the .woff2 files, and paste everything into your app. Then the CSS still points to the old remote URL, or points to a path that doesn’t exist in production.
Example of a bad self-hosted CSS file:
@font-face {
font-family: 'Roboto';
src: url('./roboto-v30-latin-regular.woff2') format('woff2');
}
That might work in one directory layout and fail in another. Or you accidentally leave the original source in place:
@font-face {
font-family: 'Roboto';
src: url('https://fonts.gstatic.com/s/roboto/...') format('woff2');
}
Then your CSP blocks it because font-src 'self' is doing its job.
Use explicit, predictable paths:
@font-face {
font-family: 'Roboto';
src: url('/assets/fonts/roboto-v30-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
When debugging, check the browser console first. CSP errors are usually very specific about which directive blocked the request.
Mistake 5: Serving fonts with the wrong MIME type
Browsers are more forgiving than they should be, until they aren’t. I’ve seen fonts served as application/octet-stream, text/plain, and once as text/html because the file path hit a 404 page that still returned 200.
CSP won’t save you from bad server config.
For .woff2, serve:
Content-Type: font/woff2
For nginx:
types {
font/woff2 woff2;
}
For Apache:
AddType font/woff2 .woff2
Then verify the actual network response in devtools.
If the font request is blocked, don’t assume CSP immediately. Check:
- status code
- content type
- actual response body
- whether the URL is what you think it is
I waste less time when I debug in that order.
Mistake 6: Forgetting CORS when fonts come from a different subdomain
You said self-hosted, but plenty of teams mean “hosted by us somewhere” — like static.examplecdn.com while the app lives on www.example.com.
That still affects CSP.
You need to allow the font origin:
Content-Security-Policy:
default-src 'self';
style-src 'self';
font-src 'self' https://static.examplecdn.com;
And the font server may also need CORS headers:
Access-Control-Allow-Origin: https://www.example.com
If you truly want the simplest CSP, serve the CSS and font files from the same origin as the page. Same origin removes a whole category of weirdness.
Mistake 7: Putting fonts under default-src and assuming that’s enough
Technically, if font-src is missing, browsers fall back to default-src. Same for style-src.
That leads people to write:
Content-Security-Policy:
default-src 'self';
And call it done.
I don’t like that for production policies. It’s too implicit. The moment someone changes default-src for another reason, font loading changes too.
Be explicit:
Content-Security-Policy:
default-src 'self';
style-src 'self';
font-src 'self';
img-src 'self' data:;
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
Explicit directives make reviews easier and breakages easier to diagnose.
Official CSP directive docs: MDN Content-Security-Policy.
Mistake 8: Not versioning font files and then fighting stale caches
This isn’t a CSP violation, but it causes the same “why are fonts broken” panic.
You update your font file, but the browser keeps an older cached version while the CSS points to the same filename. Suddenly one environment renders differently from another.
Use fingerprinted assets:
@font-face {
font-family: 'Inter';
src: url('/assets/fonts/inter-var.8f3a1c.woff2') format('woff2');
font-display: swap;
}
And serve them with aggressive caching:
Cache-Control: public, max-age=31536000, immutable
Then version the CSS too if needed.
CSP doesn’t manage cache correctness. You still need sane asset strategy.
Mistake 9: Copy-pasting a huge CSP and never trimming it
The headertest.com policy is a good example of a realistic production CSP with analytics, consent tooling, frames, websockets, and nonces:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-OTAzYmJjZGUtYmZhYi00MDQ3LWIwYzQtNTZiZTFmZGNmZTEx' '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 a normal policy for a real app. But don’t cargo-cult a header like that into your own site just because one directive happens to match your font setup.
For self-hosted fonts, the relevant part is tiny:
style-src 'self';
font-src 'self';
Everything else should reflect your actual app, not somebody else’s stack.
A clean baseline policy for self-hosted fonts
If you want a straightforward starting point, I’d use something like this:
Content-Security-Policy:
default-src 'self';
style-src 'self';
font-src 'self';
img-src 'self' data:;
script-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
And a matching font stylesheet:
@font-face {
font-family: 'Inter';
src: url('/assets/fonts/inter-latin-400-normal.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/assets/fonts/inter-latin-700-normal.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
body {
font-family: 'Inter', system-ui, sans-serif;
}
That’s the boring setup I want. Boring is good. Boring survives production.
If your self-hosted Google Fonts setup needs a bunch of extra CSP exceptions, I’d assume something else is going on and audit it before shipping.