If you’re tuning CSS in production, you’ll usually end up doing two things:
- minifying with
cssnano - removing unused selectors with a purge step
Those are build-time optimizations, so people assume CSP has nothing to do with them. That assumption bites later.
CSP does not care that your CSS was generated by PostCSS, cssnano, Tailwind, PurgeCSS, or a custom pipeline. CSP only sees what the browser sees: where styles came from, whether they were inline, and whether some script injected them at runtime.
That matters because purge and minification often change how styles are delivered:
- a framework falls back to runtime style injection
- critical CSS gets inlined into the page
- dynamically generated class names get purged, then JavaScript starts patching styles inline
- third-party consent or analytics tools inject style blocks
Now you’ve got a CSP problem that looks like a CSS optimization problem.
First: cssnano does not purge CSS
This naming confusion is common.
cssnano is a minifier. It compresses CSS, merges rules, normalizes values, and removes whitespace. It does not detect unused selectors. Purging is usually handled by tools like:
@fullhuman/postcss-purgecss- framework-integrated purge features
- custom extraction logic
A typical PostCSS setup looks like this:
// postcss.config.cjs
const cssnano = require('cssnano')
const purgecss = require('@fullhuman/postcss-purgecss')
module.exports = {
plugins: [
purgecss({
content: ['./layouts/**/*.html', './content/**/*.md', './assets/js/**/*.js'],
safelist: ['is-active', 'open', /^cookiebot/]
}),
cssnano({
preset: 'default'
})
]
}
The CSP angle starts when this optimized CSS is shipped.
The CSP directives that matter for CSS
For CSS delivery, I care about these directives first:
style-srcdefault-src- sometimes
font-src - sometimes
img-srcif CSS references external images - sometimes
connect-srcif runtime tooling fetches assets
If style-src is not defined, the browser falls back to default-src. That can create weird breakage if you lock down default-src 'self' and forget that your CSS is hosted elsewhere.
For directive details, the official reference is the MDN CSP documentation, and for practical breakdowns I often point people at https://csp-guide.com.
A real-world header and what it tells us
Here’s the real CSP header you provided from headertest.com:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-MTRiNTM3M2UtMDVlMi00NDBjLTg0MjMtMzkyNDcwNmMzYzU0' '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://collect.tallytics.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 CSS-specific takeaway is this part:
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
That policy allows:
- local stylesheets from
'self' - inline
<style>blocks andstyle=""attributes because of'unsafe-inline' - styles from some third-party domains
From a security perspective, 'unsafe-inline' is the weak spot. It’s often left in place because some build or plugin pipeline injects CSS at runtime, or because critical CSS is embedded directly in templates.
If your cssnano/purge setup is clean, you can often remove 'unsafe-inline'.
The safest deployment model
I strongly prefer this model:
- purge unused selectors at build time
- minify with cssnano at build time
- emit one or more static
.cssfiles - serve them from your own origin
- use
style-src 'self' - avoid runtime style injection entirely
Example CSP:
Content-Security-Policy:
default-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
And the HTML:
<link rel="stylesheet" href="/css/site.min.css">
That’s the boring setup. Boring is good.
Build pipeline example with purge + cssnano
Here’s a practical setup for a Hugo site using PostCSS.
postcss.config.cjs
const purgecss = require('@fullhuman/postcss-purgecss')
const cssnano = require('cssnano')
module.exports = {
plugins: [
purgecss({
content: [
'./layouts/**/*.html',
'./content/**/*.md',
'./assets/js/**/*.js'
],
defaultExtractor: content =>
content.match(/[\w-/:.%]+(?<!:)/g) || [],
safelist: [
'is-open',
'is-active',
'hidden',
/^cookiebot/,
/^ga-/
]
}),
cssnano({
preset: ['default', {
discardComments: { removeAll: true }
}]
})
]
}
Hugo template
{{ $css := resources.Get "css/site.css" | postCSS | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
This does not change CSP by itself, but it supports a strong policy because the result is an external stylesheet, not inline CSS.
Where purge breaks CSP planning
Purge tools can remove selectors that are only created dynamically in JavaScript.
Example:
menu.classList.add('is-open')
banner.classList.add(`theme-${userTheme}`)
If purge misses those classes, the UI breaks. Teams often “fix” that by injecting inline styles at runtime:
menu.style.display = 'block'
banner.style.backgroundColor = '#0366d6'
Now your CSP may require 'unsafe-inline' for style attributes, which is exactly what you were trying to avoid.
The better fix is to safelist dynamic classes in the purge step:
purgecss({
content: ['./layouts/**/*.html', './assets/js/**/*.js'],
safelist: [
'is-open',
/^theme-/
]
})
Then keep using class toggles instead of inline style mutation.
Critical CSS and inline styles
A lot of performance setups inline “critical CSS” in the document head:
<style>
.hero{display:grid;min-height:60vh}
.nav{display:flex;gap:1rem}
</style>
That will be blocked unless your CSP allows inline styles.
You have three options:
1. Keep 'unsafe-inline'
Fast to ship, weakest security.
style-src 'self' 'unsafe-inline';
2. Use a nonce on the <style> block
Much better.
style-src 'self' 'nonce-r4nd0m123';
<style nonce="r4nd0m123">
.hero{display:grid;min-height:60vh}
.nav{display:flex;gap:1rem}
</style>
3. Move critical CSS into a real file
My preference unless you’ve measured a real performance benefit.
<link rel="stylesheet" href="/css/critical.min.css">
<link rel="stylesheet" href="/css/site.min.css">
For most sites, option 3 is the easiest way to keep CSP tight.
Third-party tools are usually why style-src gets ugly
Look back at the real header. It explicitly allows Cookiebot and Google Tag Manager sources in style-src.
That’s a sign that third-party tooling may inject or load styles. Consent managers especially love to do this.
If you remove 'unsafe-inline', test these flows carefully:
- cookie banner rendering
- analytics consent UI
- tag manager-injected widgets
- A/B testing tools
- chat widgets
You may need something like:
style-src 'self' https://*.cookiebot.com https://consent.cookiebot.com;
But I would challenge every extra source. If a vendor forces broad style allowances, that’s a real security cost.
A stricter rewrite of the real header
If the site can eliminate inline styles and keep third-party CSS limited, I’d aim closer to 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' https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://collect.tallytics.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 main win is dropping:
'unsafe-inline'
from style-src.
That only works if your CSS pipeline and third-party scripts stop depending on inline style blocks or style="" attributes.
Debugging CSP violations caused by CSS changes
When I tighten style-src, I watch the browser console for messages like:
- refused to apply inline style
- refused to load stylesheet
- refused to apply a style because it violates
style-src
A simple rollout pattern is to start in report-only mode:
Content-Security-Policy-Report-Only:
default-src 'self';
style-src 'self';
report-to default-endpoint;
Then fix what breaks before enforcing.
If a purge change suddenly creates CSP violations, that usually means one of these happened:
- a class got removed, and JS started using inline styles as a fallback
- critical CSS got moved inline
- a plugin switched from static CSS extraction to runtime injection
- a third-party script started inserting
<style>tags
My rule of thumb
If you’re using cssnano and a purge step, CSP should get simpler, not more permissive.
A healthy setup looks like this:
- purge only what you can prove is unused
- safelist dynamic classes deliberately
- emit static CSS files
- serve them from
'self' - avoid inline styles
- remove
'unsafe-inline'fromstyle-srcwhen possible
That’s the sweet spot: smaller CSS, fewer moving parts, and a policy that actually blocks something useful.
For the actual CSP syntax and directive behavior, stick to the official MDN documentation. For practical directive examples and tradeoffs, https://csp-guide.com is a good companion reference.