UnoCSS is great until you turn on a strict Content Security Policy and your styling quietly falls apart.
That’s the tradeoff with on-demand atomic CSS. UnoCSS can inject styles at runtime, and CSP tends to hate runtime injection unless you explicitly allow it. If you’ve ever tightened style-src and then watched your app render as unstyled HTML, you’ve already met the problem.
The short version: if UnoCSS is generating or injecting <style> tags in the browser, you need to account for that in CSP. If you can shift style generation to build time or server render time, life gets much easier.
Why UnoCSS and CSP collide
CSP controls where scripts, styles, images, fonts, and connections can come from.
For styles, the key directive is style-src.
A strict policy usually looks something like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-random123';
style-src 'self';
img-src 'self' data:;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
That style-src 'self' allows stylesheets loaded from your own origin, like:
<link rel="stylesheet" href="/assets/app.css">
But it does not allow inline style blocks:
<style>
.text-red { color: red; }
</style>
And it also blocks style tags dynamically inserted by JavaScript unless you use a nonce or hash strategy that the browser accepts.
That’s where UnoCSS can become awkward. Depending on how you use it, it may:
- generate CSS at build time into a static file
- generate CSS during SSR and inline it
- inject CSS on the client during development or runtime
Only the first option is naturally CSP-friendly.
The safest approach: build UnoCSS into a static stylesheet
If you can produce a real CSS file and serve it from your own domain, do that.
For a Vite app using UnoCSS, your setup often looks like this:
// vite.config.ts
import { defineConfig } from 'vite'
import UnoCSS from 'unocss/vite'
export default defineConfig({
plugins: [
UnoCSS(),
],
})
And in your app entry:
// main.ts
import 'uno.css'
That import is the sweet spot. In production, Vite will usually emit a static CSS asset. Then your CSP can stay simple:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
If your production build emits external CSS files, UnoCSS is basically a non-issue for CSP.
The trouble starts when styles are inlined or injected dynamically.
Development mode is usually not representative
Vite dev server and UnoCSS dev tooling often inject styles dynamically for fast updates. That means CSP failures in development don’t always mean production will fail the same way.
I’ve seen people loosen production CSP because dev mode was broken. Bad move.
Check the actual production HTML and network output:
- Are styles coming from
/assets/*.css? - Is UnoCSS inserting a
<style>tag into the page? - Is SSR embedding CSS inline in the document head?
Use browser DevTools and inspect the final markup. Also run the deployed site through a header checker like headertest.com to verify the policy you think you shipped is actually being sent.
For reference, a real CSP header from that site looks like this:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-M2U0N2UzMWYtOTM2Mi00NzVlLWJlNWUtODExMGU1MmNlZjMx' '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'
Notice the style-src 'unsafe-inline'. That’s common on real sites because third-party tools and inline styles are still annoyingly common. But if you control the app, I’d avoid unsafe-inline unless you’ve run out of better options.
If UnoCSS injects inline styles, you have three options
1. Best: stop injecting styles at runtime
This is the cleanest fix. Push UnoCSS output into external CSS files during build or SSR.
For Vite, the default production build often already does this if you import uno.css. Verify that your generated HTML uses <link rel="stylesheet"> and not inline <style>.
2. Accept inline styles with 'unsafe-inline'
This works, but it weakens CSP.
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
That will allow inline <style> blocks and style="" attributes. It’s easy, and lots of sites do it, but I don’t love it. If an attacker finds an HTML injection bug, unsafe-inline gives them more room to make malicious content look convincing or exfiltrate data through CSS tricks.
Use it if you must. Don’t pretend it’s ideal.
3. Use a nonce on style tags
If your framework or SSR layer lets you attach a nonce to the generated <style> tag, this is much better.
Your header:
// Express example
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64')
res.locals.nonce = nonce
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' data:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join('; ')
)
next()
})
Then in your HTML template:
<head>
<style nonce="{{nonce}}">
/* UnoCSS output here */
</style>
</head>
If you’re doing SSR and can intercept the rendered head output, add the nonce there.
This is the right answer when styles must be inline per request.
Nuxt + UnoCSS + CSP
Nuxt apps often mix SSR, module-generated head content, and third-party integrations, so CSP gets messy fast.
If you use @unocss/nuxt, first check whether production emits CSS assets or inline styles. If it’s external CSS, style-src 'self' is enough.
If styles are inlined during SSR, you’ll need either:
style-src 'self' 'unsafe-inline'- or a nonce-based setup if Nuxt lets you thread the nonce into generated style tags
A rough nonce setup in a Nitro-compatible server flow looks like this conceptually:
// server/middleware/csp.ts
import { randomBytes } from 'node:crypto'
export default defineEventHandler((event) => {
const nonce = randomBytes(16).toString('base64')
event.context.cspNonce = nonce
setHeader(event, 'Content-Security-Policy', [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' data:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join('; '))
})
Then expose event.context.cspNonce to the render layer and apply it to any inline <style> or <script> tags.
The exact mechanics depend on your Nuxt version and rendering path, but the security model stays the same.
Hashes are possible, but usually annoying
CSP also supports hashes for inline styles:
style-src 'self' 'sha256-AbCdEf123...'
That only works if the inline CSS content is stable. With UnoCSS, generated CSS tends to change whenever classes change, which means hash management becomes fragile. Fine for tiny static snippets, bad for generated utility output.
I almost always prefer:
- external stylesheet
- or nonce for SSR inline styles
A practical CSP for UnoCSS apps
If UnoCSS is fully externalized in production:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
If SSR requires inline styles and you can add a nonce:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
style-src 'self' 'nonce-{RANDOM}';
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
If you can’t avoid inline styles:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
If you need a refresher on what style-src, nonces, and hashes actually do, csp-guide.com is a solid reference.
Debugging CSP breakage with UnoCSS
When styles disappear, check these in order:
-
Look for CSP errors in DevTools Browser consoles usually tell you exactly what was blocked.
-
Inspect the DOM Are styles in a
<link>tag or a<style>tag? -
Check production HTML, not just dev Dev mode lies. Or at least it behaves very differently.
-
Search for
style=attributes Even if UnoCSS is fine, some component library may be using inline styles and forcingunsafe-inline. -
Use Report-Only first Start with:
Content-Security-Policy-Report-Only: ...
That lets you collect violations without breaking the app.
My recommendation
For UnoCSS, I’d aim for this order of preference:
- External generated CSS with
style-src 'self' - SSR inline CSS with a per-request nonce
'unsafe-inline'only if the stack won’t cooperate
UnoCSS itself isn’t the security problem. Runtime style injection is. If you treat UnoCSS as a build-time CSS generator, CSP stays straightforward. If you rely on inline or dynamic style tags, you need to make a deliberate CSP exception.
That’s really the whole game: make style delivery boring, and CSP becomes boring too. That’s what you want.