I’ve had to fix this exact problem more than once: a site adds KaTeX for math rendering, everything works locally, then production CSP turns it into a pile of console errors.

KaTeX itself is usually not the hard part. The hard part is fitting it into a CSP that already has analytics, consent tooling, nonces, and a bunch of inherited decisions nobody wants to touch.

Here’s a real-world style case study based on a production-shaped policy, using the kind of CSP you actually see on a live site.

The starting point

A real CSP header 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-MjIyM2U0MzQtMjVlMi00MjE0LWJkMTYtNzkwYjY0ZDQ0NzYx' '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 policy. It has some strong choices:

  • object-src 'none'
  • frame-ancestors 'none'
  • base-uri 'self'
  • nonce-based script-src
  • strict-dynamic

But it also has some common baggage:

  • style-src 'unsafe-inline'
  • a bunch of third-party allowances mixed into default-src
  • font-src 'self', which is often too strict for frontend libraries unless you self-host everything

Now drop KaTeX into this setup and see what breaks.

The feature request

The dev team wanted server-rendered docs pages with inline math and display equations:

<p>Einstein wrote \(E = mc^2\)</p>

<div>
  \[
    \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
  \]
</div>

They added KaTeX the usual way:

<link rel="stylesheet" href="/assets/katex/katex.min.css">
<script defer src="/assets/katex/katex.min.js"></script>
<script defer src="/assets/katex/contrib/auto-render.min.js"></script>

<script nonce="{{ .CSPNonce }}">
  document.addEventListener("DOMContentLoaded", function () {
    renderMathInElement(document.body, {
      delimiters: [
        { left: "$$", right: "$$", display: true },
        { left: "\\[", right: "\\]", display: true },
        { left: "\\(", right: "\\)", display: false }
      ]
    });
  });
</script>

This is already the right instinct: self-host KaTeX, use a nonce for the init script, avoid random CDN exceptions.

But production still broke.

What actually failed

There were two issues.

1. Fonts were blocked

KaTeX uses web fonts. If the CSS is loaded from /assets/katex/katex.min.css, the browser will fetch fonts referenced by that stylesheet, usually under something like:

/assets/katex/fonts/KaTeX_Main-Regular.woff2
/assets/katex/fonts/KaTeX_Math-Italic.woff2

If you self-host those fonts under the same origin, font-src 'self' is fine.

But I’ve seen teams copy only the CSS and JS files, forget the fonts, and then silently fall back to broken rendering. The page “works,” but spacing and symbols are off. That’s worse than a hard failure because people miss it in review.

Typical console error when fonts are missing or blocked:

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

2. Inline styles tempted the team into bad CSP changes

KaTeX itself does not require inline script execution in the browser if you use it sanely. But teams often react to rendering glitches by loosening CSP too much:

  • adding 'unsafe-inline' to script-src
  • allowing random font CDNs
  • broadening style-src because “math is broken”

That’s the wrong fix.

The existing policy already had 'unsafe-inline' in style-src, which is common but not great. KaTeX did not need that. It just happened to already be there.

The bad “before” fix

I’ve seen this kind of policy change get proposed during rollout:

Content-Security-Policy:
  default-src 'self' https:;
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https:;
  style-src 'self' 'unsafe-inline' https:;
  img-src 'self' data: https:;
  font-src 'self' https: data:;
  connect-src 'self' https: wss:;
  frame-src 'self' https:;
  object-src 'none';

This is how security gets chipped away one support ticket at a time.

Problems with this version:

  • 'unsafe-inline' in script-src destroys a major CSP protection
  • 'unsafe-eval' is almost never justified for KaTeX
  • https: in multiple directives is way too broad
  • font-src 'self' https: data: allows a lot more than needed

This kind of policy “fixes” KaTeX by making CSP less meaningful.

The actual fix

We kept the existing strong parts and changed only what KaTeX truly needed.

Safer production setup

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' '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 change was not adding permissions. It was making sure KaTeX assets were properly self-hosted:

  • /assets/katex/katex.min.css
  • /assets/katex/katex.min.js
  • /assets/katex/contrib/auto-render.min.js
  • /assets/katex/fonts/*.woff2

That let us keep:

font-src 'self';

No font CDN. No blanket https:. No data: for fonts.

Before and after in the template

Before: brittle and CSP-hostile

<link rel="stylesheet" href="https://cdn.example.com/katex.min.css">
<script src="https://cdn.example.com/katex.min.js"></script>
<script src="https://cdn.example.com/auto-render.min.js"></script>
<script>
  document.addEventListener("DOMContentLoaded", function () {
    renderMathInElement(document.body);
  });
</script>

Why this is bad:

  • external CDN means more CSP exceptions
  • inline script needs 'unsafe-inline' or a nonce/hash
  • if the CDN CSS points to CDN fonts, font-src 'self' breaks

After: self-hosted and nonce-friendly

<link rel="stylesheet" href="/assets/katex/katex.min.css">
<script defer src="/assets/katex/katex.min.js"></script>
<script defer src="/assets/katex/contrib/auto-render.min.js"></script>
<script nonce="{{ .CSPNonce }}">
  document.addEventListener("DOMContentLoaded", function () {
    renderMathInElement(document.body, {
      delimiters: [
        { left: "$$", right: "$$", display: true },
        { left: "\\[", right: "\\]", display: true },
        { left: "\\(", right: "\\)", display: false }
      ],
      throwOnError: false
    });
  });
</script>

This version works with a modern nonce-based script-src and doesn’t force you to weaken font-src.

A better cleanup pass

If I were cleaning this up beyond “make KaTeX work,” I’d also tighten the original policy structure.

The source policy puts third-party hosts into default-src. I don’t love that. I’d rather make default-src 'self' and explicitly list third parties only where needed.

Something like this:

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' '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 makes the policy easier to reason about. default-src should be boring.

If you want a good directive-by-directive reference, https://csp-guide.com is useful, and the official CSP behavior is documented in the MDN Content Security Policy docs.

Practical rules for KaTeX under CSP

My opinionated checklist:

  1. Self-host KaTeX JS, CSS, and fonts

    • Don’t half-copy the package.
    • Fonts matter.
  2. Use a nonce for the initialization script

    • If you already have nonce-based CSP, this is easy.
    • Don’t backslide into 'unsafe-inline' for scripts.
  3. Do not add unsafe-eval

    • KaTeX doesn’t need it in a normal setup.
  4. Keep font-src narrow

    • font-src 'self' is ideal if assets are local.
    • Avoid https: unless you truly need it.
  5. Don’t blame style-src for every rendering issue

    • A lot of “KaTeX CSP problems” are actually missing fonts.
  6. Test with the network tab open

    • You’ll catch blocked .woff2 files immediately.

The result

After the fix:

  • math rendered correctly
  • no new third-party origins were added
  • script-src stayed nonce-based
  • font-src 'self' remained intact
  • no need for 'unsafe-eval'
  • no need to weaken CSP just to support math

That’s the real lesson here. KaTeX is CSP-friendly if you deploy it like you mean it. Most breakage comes from sloppy asset hosting, not from the library itself.

If a team tells me “we had to loosen CSP for KaTeX,” I usually read that as: “we took the quick path and let security pay for it.”