CSS got weird again, in a good way. Between @layer, design tokens, component libraries, runtime theming, and CSS-in-JS leftovers, a lot of teams now have what I’d call an LSD setup: a Layer System for CSS.

You split styles into predictable layers like reset, tokens, base, components, utilities, and overrides. That’s cleaner than the old cascade knife fight. But CSP can get awkward fast, especially when your CSS pipeline injects styles, swaps themes at runtime, or leans on inline <style> blocks.

If you run Content-Security-Policy without thinking about your CSS architecture, you’ll either break the site or give up and slap 'unsafe-inline' into style-src. I’ve seen both. Usually on the same project.

Here’s how to do CSP for layered CSS systems without wrecking developer ergonomics.

What “LSD” means here

I’m using LSD as shorthand for a layered CSS architecture, usually something like this:

@layer reset, tokens, base, components, utilities, overrides;

@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
}

@layer tokens {
  :root {
    --color-brand: #0a66ff;
    --space-2: 0.5rem;
  }
}

@layer base {
  body {
    margin: 0;
    font-family: system-ui, sans-serif;
  }
}

@layer components {
  .btn {
    background: var(--color-brand);
    color: white;
    padding: var(--space-2) 1rem;
  }
}

@layer utilities {
  .hidden { display: none; }
}

@layer overrides {
  .marketing .btn { border-radius: 999px; }
}

This has nothing to do with CSP by itself. CSP starts mattering when these layers are delivered through:

  • external stylesheets
  • inline <style> blocks
  • dynamically injected style tags
  • style="" attributes
  • CSS-in-JS runtimes
  • theme editors or CMS-controlled snippets

That’s where your policy needs to match reality.

The CSP directives that matter for CSS

For layered CSS systems, the main directive is style-src.

A basic version looks like this:

Content-Security-Policy: default-src 'self'; style-src 'self';

That means:

  • CSS can load from the same origin
  • inline <style> blocks are blocked
  • style="" attributes are blocked
  • JS-created style tags are blocked unless they match a nonce or hash policy

If you need a refresher on directive behavior, csp-guide.com is a solid reference.

The first rule: keep layered CSS in external files

If your LSD setup compiles to static CSS files, CSP is easy.

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

Then your policy can stay tight:

Content-Security-Policy:
  default-src 'self';
  style-src 'self';
  font-src 'self';
  img-src 'self' data: https:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

That’s the happy path. Boring is good here.

If you want a quick way to inspect your live headers and spot weak directives, I’d use headertest.com. It’s handy when you’re checking whether your production CDN, app server, and edge config are actually sending what you think they are.

Where layered CSS usually collides with CSP

The usual problems are:

  1. Inline critical CSS
  2. Theme switching that injects <style>
  3. Component libraries that write styles at runtime
  4. CMS snippets with inline style attributes
  5. Third-party consent or analytics tools adding styles

Let’s deal with them one by one.

Inline critical CSS: use a nonce or hash, not unsafe-inline

A lot of teams inline a small layered chunk for above-the-fold rendering:

<style>
  @layer tokens, base;
  @layer tokens {
    :root { --color-brand: #0a66ff; }
  }
  @layer base {
    body { margin: 0; }
  }
</style>

This will be blocked by style-src 'self'.

The lazy fix is:

style-src 'self' 'unsafe-inline'

I don’t recommend that unless you truly can’t avoid it. It opens the door to inline style injection and weakens the policy a lot.

Use a nonce instead:

<style nonce="{{ .CSPNonce }}">
  @layer tokens, base;
  @layer tokens {
    :root { --color-brand: #0a66ff; }
  }
  @layer base {
    body { margin: 0; }
  }
</style>

Then send:

Content-Security-Policy:
  default-src 'self';
  style-src 'self' 'nonce-rAnd0m123';

In an Express app:

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'",
      `style-src 'self' 'nonce-${nonce}'`,
      "script-src 'self'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "object-src 'none'",
      "base-uri 'self'",
      "frame-ancestors 'none'"
    ].join("; ")
  );

  next();
});

app.get("/", (req, res) => {
  res.send(`
    <!doctype html>
    <html>
      <head>
        <style nonce="${res.locals.nonce}">
          @layer tokens, base;
          @layer tokens { :root { --color-brand: #0a66ff; } }
          @layer base { body { margin: 0; font-family: sans-serif; } }
        </style>
        <link rel="stylesheet" href="/css/app.css">
      </head>
      <body>Hello</body>
    </html>
  `);
});

That’s a decent compromise when you really need inline CSS.

Dynamic style injection: nonces can work, but static CSS is better

Some UI libraries inject <style> tags at runtime. If your JS creates a style element, CSP still applies.

const style = document.createElement("style");
style.textContent = `
  @layer overrides {
    .dialog { inset: 2rem; }
  }
`;
document.head.appendChild(style);

Without 'unsafe-inline', that gets blocked.

One option is setting a nonce on the generated style tag:

const nonce = document
  .querySelector('meta[name="csp-nonce"]')
  ?.getAttribute("content");

const style = document.createElement("style");
style.setAttribute("nonce", nonce);
style.textContent = `
  @layer overrides {
    .dialog { inset: 2rem; }
  }
`;
document.head.appendChild(style);

Server-rendered HTML:

<meta name="csp-nonce" content="{{ .CSPNonce }}">

Policy:

style-src 'self' 'nonce-...'

This works, but I still think runtime CSS injection should be treated as a smell in most apps. For an LSD setup, the whole point is predictable layering. Shipping prebuilt layer files is more stable, easier to cache, and less annoying to secure.

Style attributes are a separate mess

This will also be blocked under a strict policy:

<div style="color: red">Warning</div>

If your CMS or component system sprays inline style attributes everywhere, CSP gets painful fast. Best fix: stop doing that and move styles into classes and layers.

Bad:

<div style="margin-top: 12px; background: #eee"></div>

Better:

<div class="card card--spaced"></div>
@layer components {
  .card {
    background: #eee;
  }
}

@layer utilities {
  .card--spaced {
    margin-top: 12px;
  }
}

A layered CSS system actually helps here because it gives you a structured place for utility and override classes instead of reaching for style="".

Real-world policy example

Here’s a real CSP header from headertest.com:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-NTRmMjljYzUtZjk3Mi00ZWM0LWI3MmQtZGNmNTE3YjVhMDJh' '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'

A few observations:

  • script-src is pretty modern: nonce + 'strict-dynamic'
  • object-src 'none', base-uri 'self', and frame-ancestors 'none' are exactly what I like to see
  • style-src still includes 'unsafe-inline'

That last bit is common. Third-party consent tools and tag managers often push teams into weaker style policies. If your LSD architecture is clean but a vendor injects inline styles, your policy may end up looser than you’d like.

My advice: keep your own CSS pipeline nonce-free if possible by using external files, and isolate third-party exceptions to the minimum you can get away with.

A practical CSP for a layered CSS app

Here’s a good starting point for an app using external layered CSS plus a tiny nonce’d critical style block:

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';

And the HTML:

<head>
  <style nonce="{{ .CSPNonce }}">
    @layer tokens {
      :root { --brand: #0a66ff; }
    }
  </style>

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

That gives you:

  • strict defaults
  • no blanket inline CSS allowance
  • support for critical CSS
  • room for layered architecture without runtime hacks

What I’d avoid

If I were reviewing an LSD-style frontend, I’d push back on these:

1. style-src 'unsafe-inline' as the default answer

Sometimes unavoidable. Often laziness.

2. CSS-in-JS runtimes for mostly static component styling

If your styles can be built ahead of time, build them ahead of time.

3. CMS-authored inline styling

That turns CSP into a negotiation with content editors. Bad trade.

4. Massive third-party styling dependencies

Every extra domain in style-src is another thing to trust.

A sane migration path

If your current site is messy, don’t try to jump straight to a perfect policy.

Start here:

  1. Move as much CSS as possible into external layered files
  2. Replace inline style attributes with classes
  3. Add CSP in Report-Only mode
  4. Fix violations
  5. Use nonces for the inline CSS you truly need
  6. Remove 'unsafe-inline' from style-src if your stack allows it

Example report-only header:

Content-Security-Policy-Report-Only:
  default-src 'self';
  style-src 'self' 'nonce-test123';
  report-to default-endpoint;

That lets you see what breaks before enforcement.

Final opinion

Layered CSS and CSP actually fit together pretty well. LSD gives you structure. CSP rewards structure. The pain starts when styling becomes dynamic, ad hoc, and scattered across templates, JS runtimes, and third-party widgets.

My default stance is simple:

  • external layered CSS first
  • nonce inline CSS only when necessary
  • avoid 'unsafe-inline'
  • treat runtime style injection as an exception, not architecture

If your CSS system is predictable, your CSP can be strict. That’s the real win.