Teams usually discover CSP problems with Linaria the annoying way: everything works in development, then production gets a stricter policy and styles start disappearing.
I’ve seen this happen when a team moves from a relaxed policy to something closer to a real production header, like the one headertest.com sends:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-MjgzMGM0NjctNzg4MS00NTNiLThkN2UtNjY3N2VmMTRlOGUy' '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 header is realistic because it shows the usual compromise: strong script controls, but style-src 'unsafe-inline' left behind because CSS tooling got messy.
Linaria is one of the few CSS-in-JS tools that gives you a cleaner path. Since Linaria extracts CSS at build time, you can usually avoid the classic CSP disaster where your app needs runtime <style> injection. That’s the good news.
The bad news: “usually” is doing a lot of work there.
The setup
The app in this case was a React SSR app using Linaria for component styles. The team wanted to remove unsafe-inline from style-src and keep a nonce-based CSP for scripts.
Their first assumption was reasonable:
- Linaria extracts CSS
- extracted CSS means external stylesheets
- external stylesheets should work with
style-src 'self' - therefore CSP is easy
That was mostly true in production builds, but not everywhere.
Before: the half-secure policy
Here’s the kind of header they had before:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
This is common. Scripts are locked down properly, but styles get a free pass.
Why teams leave it this way:
- Some framework code injects styles at runtime
- development tooling definitely injects styles
- nobody wants to break the UI over CSP
- people assume CSS is lower risk than script
That last point is shaky. Inline CSS is not as dangerous as inline JS, but allowing unsafe-inline still weakens the policy and removes a useful protection layer. If you care enough to run a nonce-based script-src, you should care about cleaning up style-src too.
What actually broke
When they removed unsafe-inline and switched to this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
production mostly survived.
But they still got CSP violations in two places:
- SSR output included a small inline
<style>block for critical CSS on some routes - local development used style injection from the dev server, which failed immediately
The browser console looked like this:
Refused to apply inline style because it violates the following Content Security Policy directive:
"style-src 'self'".
Either the 'unsafe-inline' keyword, a hash, or a nonce is required to enable inline execution.
That error message is the whole story. Linaria itself wasn’t really the problem. The surrounding rendering pipeline was.
The root cause
Linaria can work in two broad modes from a CSP perspective:
1. Best case: extracted CSS files
Your components:
import { styled } from '@linaria/react';
export const Button = styled.button`
background: rebeccapurple;
color: white;
border: 0;
padding: 0.75rem 1rem;
`;
Build output gives you CSS assets served like:
<link rel="stylesheet" href="/assets/app.82c1f.css">
This is ideal for CSP. No inline styles needed. style-src 'self' is enough.
2. Messier case: inline critical CSS or dev injection
Some SSR integrations collect styles and emit them inline:
<style>
.Button_ab12cd { background: rebeccapurple; color: white; }
</style>
That breaks under style-src 'self'.
And during development, bundlers often inject CSS through JS:
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
That also breaks unless the created <style> gets a valid nonce, or you loosen the policy for development.
The fix
The production fix was simple once the team stopped treating “Linaria” as the issue and looked at the actual HTML output.
After: production header
They ended up with this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self' 'nonce-{RANDOM_NONCE}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
That does two things:
- allows external stylesheets from self
- allows only nonce-approved inline
<style>blocks when they’re truly needed
That is a much better place to be than style-src 'unsafe-inline'.
If you want a refresher on directive behavior, the official CSP docs are the right source, and https://csp-guide.com has good directive-level examples.
Server-side nonce generation
The app was already generating a nonce for scripts, so the clean move was to reuse the same per-request nonce for style tags.
Node/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}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; ')
);
next();
});
Then in the HTML template:
export function Html({
nonce,
cssText,
appHtml,
}: {
nonce: string;
cssText?: string;
appHtml: string;
}) {
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="/assets/app.css" />
${cssText ? `<style nonce="${nonce}">${cssText}</style>` : ''}
</head>
<body>
<div id="root">${appHtml}</div>
<script nonce="${nonce}" src="/assets/app.js"></script>
</body>
</html>
`;
}
If your SSR layer emits inline styles, this is the difference between a working strict policy and a broken page.
Better fix: avoid inline styles entirely in production
My preferred setup is even stricter:
- extracted Linaria CSS only
- no inline critical CSS
style-src 'self'- no style nonce needed at all
Like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
And HTML:
<link rel="stylesheet" href="/assets/app.css">
No inline <style>. No exceptions. Much easier to reason about.
If you can get Linaria and your SSR tooling to stay in that lane, do it.
Development is different
I would not force production CSP rules onto development if your tooling injects styles dynamically. That turns CSP into a productivity tax.
Use a separate dev policy, for example:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self' ws: wss: http: https:;
img-src 'self' data: https:;
font-src 'self' data:;
That’s not pretty, but dev policies don’t need to win beauty contests. Production policies do.
If your dev server supports attaching a nonce to generated <style> tags, great. Most teams don’t bother, and I think that’s fine.
Practical checklist for Linaria
If you want CSP-friendly Linaria in the real world, check these first:
- Inspect actual production HTML, not just source code
- Look for inline
<style>blocks in SSR output - Confirm whether critical CSS is inlined
- Confirm whether all component CSS is extracted to static files
- Reuse the same per-request nonce for script and style when inline tags are unavoidable
- Keep development and production CSP separate
The result
The team removed unsafe-inline from style-src in production without rewriting their styling system.
Before:
style-src 'self' 'unsafe-inline'
After:
style-src 'self' 'nonce-{RANDOM_NONCE}'
And on routes where inline styles were no longer needed:
style-src 'self'
That’s the real lesson here: Linaria is one of the better choices if you care about CSP, but only if you follow through on the build and render pipeline. Build-time CSS extraction gets you most of the way. The last 20% is auditing what your server actually emits.
A strict CSP is never just a header problem. It’s always a rendering problem too.