CSS-in-JS is great right up until you try to lock down style-src.

That’s where the friction starts. Many CSS-in-JS libraries inject <style> tags at runtime, and CSP treats those as inline styles. If your policy is strict, those styles get blocked unless you loosen style-src or add a nonce.

I’ve seen teams spend weeks tightening script-src only to quietly leave style-src 'unsafe-inline' in place because Emotion, styled-components, JSS, or a legacy UI kit needed it. That usually happens because CSS-in-JS was adopted for developer experience, while CSP got added later under security pressure.

Here’s the practical comparison guide I wish more frontend teams had before they shipped both.

The core problem

A strict CSP often starts here:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

Looks good, but many CSS-in-JS libraries will break because they generate styles dynamically and insert them into the DOM.

To make them work, teams usually pick one of these approaches:

  1. Allow style-src 'unsafe-inline'
  2. Use a nonce on generated <style> tags
  3. Render styles server-side with a nonce
  4. Avoid runtime injection by extracting CSS at build time
  5. Mix approaches and accept some debt

Each one has tradeoffs.

Option 1: style-src 'unsafe-inline'

This is the most common escape hatch.

A real-world example from headertest.com includes:

content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-YmQ2Yjg1ZjctZGIwNS00MWQyLTgzYmEtNDljZmJkZjViNjBi' '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 policy is doing a lot of good things, especially around script-src with a nonce and strict-dynamic. But style-src 'unsafe-inline' is still a meaningful concession.

Pros

  • Easiest way to make CSS-in-JS work
  • No framework changes in many setups
  • Works with third-party widgets that inject styles
  • Low engineering cost

Cons

  • Weakens CSP meaningfully for styles
  • Inline style injection becomes allowed
  • Makes it harder to claim you have a truly strict CSP
  • Often becomes permanent because nobody wants to untangle it later

My opinion: this is acceptable as a temporary migration step, not a destination.

If you need a refresher on how style-src behaves, https://csp-guide.com/style-src/ is a good reference.

Option 2: Use nonces for runtime-generated styles

This is the cleanest path when your CSS-in-JS library supports attaching a nonce to injected <style> tags.

The policy becomes:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123';
  style-src 'self' 'nonce-abc123';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

Then your app sets the same nonce on every server-approved <style> tag.

Emotion example

import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';

const cache = createCache({
  key: 'app',
  nonce: window.__CSP_NONCE__,
});

export function AppProviders({ children }) {
  return <CacheProvider value={cache}>{children}</CacheProvider>;
}

styled-components example with StyleSheetManager

import { StyleSheetManager } from 'styled-components';

export function AppProviders({ children }) {
  return (
    <StyleSheetManager nonce={window.__CSP_NONCE__}>
      {children}
    </StyleSheetManager>
  );
}

Pros

  • Stronger than unsafe-inline
  • Works well with modern CSS-in-JS libraries
  • Keeps dynamic styling possible
  • Good fit for SSR apps already generating request-scoped values

Cons

  • Every response needs a fresh nonce
  • You need to plumb that nonce through SSR, hydration, and client boot
  • Easy to break during framework upgrades
  • Some libraries or old versions don’t support nonce handling well

This is the option I recommend most often for teams that really want runtime CSS-in-JS.

Option 3: SSR with nonce-bearing style tags

If you already render styles on the server, you can attach the nonce during SSR and avoid some client-side surprises.

Example in Express

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${res.locals.nonce}'`,
      `style-src 'self' 'nonce-${res.locals.nonce}'`,
      "object-src 'none'",
      "base-uri 'self'",
      "frame-ancestors 'none'",
    ].join('; ')
  );
  next();
});

app.get('/', (req, res) => {
  const nonce = res.locals.nonce;

  res.send(`
    <!doctype html>
    <html>
      <head>
        <meta name="csp-nonce" content="${nonce}">
        <style nonce="${nonce}">body{font-family:sans-serif}</style>
      </head>
      <body>
        <div id="root"></div>
        <script nonce="${nonce}">
          window.__CSP_NONCE__ = "${nonce}";
        </script>
        <script nonce="${nonce}" src="/app.js"></script>
      </body>
    </html>
  `);
});

Pros

  • Strong CSP without unsafe-inline
  • Predictable when done fully on the server
  • Good fit for Next.js, Remix, custom React SSR, and similar stacks

Cons

  • More moving parts than people expect
  • Hydration mismatches can appear if client-side injection uses a different setup
  • Third-party components can still force exceptions

This works best when the whole rendering pipeline is under your control.

Option 4: Extract CSS at build time

This is the “stop fighting the browser” option.

Instead of injecting styles at runtime, use a library or build mode that emits static CSS files:

<link rel="stylesheet" href="/assets/app.css">

Then your policy can stay simple:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123';
  style-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

Pros

  • Best CSP compatibility
  • No inline style exceptions needed
  • Better cacheability in many cases
  • Easier to reason about in audits

Cons

  • You lose some runtime flexibility
  • The developer experience may change
  • Theme systems based on dynamic generation may need refactoring
  • Not every CSS-in-JS library supports true extraction equally well

From a security perspective, this is the cleanest answer. From a product engineering perspective, it can be expensive if your design system was built around runtime styling.

Option 5: Hybrid setup

This is what many production apps actually do.

Example:

  • extracted CSS for the app shell
  • nonce-based runtime styles for a few components
  • unsafe-inline still left in place for a consent manager or old widget

That’s often where teams land after the first CSP rollout.

Pros

  • Pragmatic
  • Lets you reduce risk incrementally
  • Easier migration path from legacy stacks

Cons

  • Harder to document
  • Easy to forget why exceptions exist
  • Can leave permanent weak spots in style-src

If you go hybrid, document every exception and put an owner on it. Otherwise “temporary” turns into three years.

Library-by-library practical guidance

Emotion

Usually one of the easier libraries to make CSP-friendly because nonce support is straightforward through the cache.

Best fit: nonce-based runtime styles or SSR with nonce

styled-components

Works reasonably well with nonce support, especially in SSR setups.

Best fit: SSR + nonce, or runtime nonce injection

JSS / older Material UI setups

Can be trickier, especially in older versions where style insertion order and nonce handling get weird.

Best fit: test carefully with nonces; consider extraction or framework upgrades

Runtime-heavy design systems

If the system generates lots of one-off styles per interaction, CSP gets harder to keep clean.

Best fit: reconsider whether runtime CSS generation is worth the policy complexity

If you’re deciding what to do, this is the order I’d use:

Choose extracted CSS when:

  • you can change the build pipeline
  • you want the strongest CSP
  • your app does not rely heavily on runtime-generated styles

Choose nonce-based CSS-in-JS when:

  • you need runtime styling
  • your framework already supports SSR
  • your library has solid nonce support

Use unsafe-inline only when:

  • you’re in migration
  • a third-party dependency forces it
  • you’ve documented the exception and planned its removal

A realistic target policy

For a modern app using CSS-in-JS with nonces, I’d aim for something like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic';
  style-src 'self' 'nonce-{RANDOM}';
  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';

If you need help understanding strict-dynamic, https://csp-guide.com/strict-dynamic/ is worth reading.

Final take

CSS-in-JS and CSP are compatible, but only if you make deliberate choices.

If your app still needs style-src 'unsafe-inline', be honest about what that means: your CSP is partially strict, not fully strict. That might be fine for now. I’ve shipped that compromise myself. But if you want a policy that holds up under scrutiny, the better paths are nonce-based style tags or build-time CSS extraction.

My bias is simple:

  • extracted CSS is best for security
  • nonced runtime styles are the practical middle ground
  • unsafe-inline is the fallback you should be trying to delete

That’s the tradeoff, and there’s no magic header that removes it.