I’ve seen this exact problem show up on developer docs sites more than once: syntax highlighting works great in local dev, then you tighten CSP in production and Prism.js suddenly becomes the thing breaking your code examples.
That’s annoying on any site. On a developer-facing site, it’s worse. Broken code blocks make the whole site feel untrustworthy.
Here’s a real-world case study for csp-examples, based on a common setup: a docs or blog site using Prism.js for syntax highlighting, plus analytics and consent tooling, with a production CSP that already looks pretty serious.
The starting point
This is the real CSP header pattern I’m using as the reference point, from headertest.com:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-YWE1YjYwYjgtZjBjYy00MDQwLWI1ZDQtOWVjMTViMGU5NThi' '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'
This is already much better than the average production header. It has:
object-src 'none'base-uri 'self'form-action 'self'frame-ancestors 'none'- a nonce on scripts
strict-dynamic
But it also has the classic compromise:
style-src 'self' 'unsafe-inline' ...
That’s usually where Prism.js sneaks into the conversation.
The actual problem with Prism.js and CSP
Prism.js itself is not always the problem. The trouble depends on how you load it and which plugins you enable.
There are three common Prism setups:
-
Static highlighting at build time
Safest. No client-side Prism needed. -
Client-side Prism core + theme CSS only
Usually CSP-friendly if scripts and styles come from self-hosted files. -
Client-side Prism with plugins that inject styles or markup behavior
This is where teams end up keeping'unsafe-inline'around longer than they should.
A lot of people blame Prism when the real issue is one of these:
- inline script used to call
Prism.highlightAll() - CDN-hosted Prism files not covered by CSP
- inline styles in the page template
- a Prism plugin or surrounding UI code mutating styles inline
- a copied CSP that allows inline styles “just to make it work”
Before: the sloppy-but-working setup
Here’s a realistic docs page template I’ve seen in the wild:
<link rel="stylesheet" href="/assets/prism.css">
<pre><code class="language-c">
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}
</code></pre>
<script src="/assets/prism.js"></script>
<script>
Prism.highlightAll();
</script>
And the matching CSP looked like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
This “works,” but there are two problems:
1. The inline Prism bootstrap script needs a nonce
If that last <script> block doesn’t get a valid nonce, highlighting fails.
Teams often forget this because Prism itself loaded fine. The browser blocks only the tiny inline call:
<script>
Prism.highlightAll();
</script>
2. 'unsafe-inline' in style-src stays forever
Once a docs site has 'unsafe-inline', nobody wants to touch it. Prism theme CSS is usually fine as an external stylesheet, but page templates, widgets, cookie banners, and random JS enhancements pile on inline styles until the policy gets stuck in a weak state.
What the browser error usually looks like
You’ll see something like:
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self' 'nonce-...'
'strict-dynamic'".
Or, if your UI around Prism uses inline style attributes:
Refused to apply inline style because it violates the following
Content Security Policy directive: "style-src 'self' ...".
If you want a quick refresher on directive behavior, the official docs are still the best place to start:
After: a cleaner Prism.js setup
The fix I prefer is boring, which is exactly why it’s good.
Step 1: self-host Prism assets
<link rel="stylesheet" href="/assets/vendor/prism/prism.min.css">
<script nonce="{{ .CSPNonce }}" src="/assets/vendor/prism/prism.min.js" defer></script>
<script nonce="{{ .CSPNonce }}" src="/assets/js/docs-init.js" defer></script>
Then move the inline bootstrap into a real file:
// /assets/js/docs-init.js
document.addEventListener("DOMContentLoaded", () => {
if (window.Prism) {
Prism.highlightAll();
}
});
That removes the need for one-off inline script blocks in content templates.
Step 2: keep Prism styling in CSS, not style attributes
Your code blocks should just be semantic HTML:
<pre class="code-block"><code class="language-c">#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}</code></pre>
And any presentation belongs in your stylesheet:
.code-block {
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
}
No style="" attributes, no JS setting element.style.
Step 3: remove 'unsafe-inline' from style-src if Prism wasn’t the real blocker
A tighter production CSP becomes possible:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{RUNTIME_NONCE}' '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';
That’s the real win. Prism keeps working, and you stop carrying inline-style risk just because a syntax highlighter exists on the page.
A practical Hugo example
Since this is for a Hugo site, I’d wire the nonce through the layout and avoid inline bootstrapping entirely.
Layout template
{{ $nonce := .Site.Params.cspNonce }}
<link rel="stylesheet" href="/assets/vendor/prism/prism.min.css">
<script nonce="{{ $nonce }}" src="/assets/vendor/prism/prism.min.js" defer></script>
<script nonce="{{ $nonce }}" src="/assets/js/docs-init.js" defer></script>
In real deployments, that nonce should be generated per request. Don’t hardcode it in config and call it done. A static nonce defeats the point.
Server-side header example
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-$request_id' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
" always;
That snippet is simplified, but the rule stands: the nonce in the header must match the nonce in the script tag for that response.
What I’d avoid
A few things I would not do unless forced:
Don’t use inline Prism.highlightAll()
It’s tiny, but it creates policy friction for no good reason.
Don’t assume Prism requires 'unsafe-inline'
Usually it doesn’t. Your templates or third-party widgets probably do.
Don’t add broad CDN allowances just for syntax highlighting
If Prism is part of your site, self-host it. It’s one less supply-chain dependency to explain later.
Don’t mix hash-based and nonce-based exceptions casually
If your site already uses runtime nonces and strict-dynamic, stay consistent.
The security impact
Was this a critical vulnerability by itself? No.
Was it a real hardening improvement? Absolutely.
The “before” version normalized inline styles and inline script glue code. That weakens the whole point of CSP, especially on a content-heavy site where templates evolve fast and multiple people touch markup.
The “after” version does a few things right:
- Prism loads from self-hosted static files
- startup logic lives in external JS
- code block styling is handled in CSS
- CSP no longer needs exceptions just to color tokens
That’s the kind of change I like: low drama, easy to review, and it makes future CSP tightening much easier.
Final before-and-after snapshot
Before
<link rel="stylesheet" href="/assets/prism.css">
<script src="/assets/prism.js"></script>
<script>
Prism.highlightAll();
</script>
style-src 'self' 'unsafe-inline';
After
<link rel="stylesheet" href="/assets/vendor/prism/prism.min.css">
<script nonce="{{ .CSPNonce }}" src="/assets/vendor/prism/prism.min.js" defer></script>
<script nonce="{{ .CSPNonce }}" src="/assets/js/docs-init.js" defer></script>
document.addEventListener("DOMContentLoaded", () => {
if (window.Prism) Prism.highlightAll();
});
style-src 'self';
That’s the real-world Prism.js CSP fix I’d ship on a developer site. Keep the highlighter, drop the unnecessary exceptions, and make CSP enforcement boring again.