CSP for SPAs: Inline Styles Without Blocking
Configure a Content Security Policy that allows inline styles in a modern SPA without weakening protections or resorting to unsafe-inline.

Start with a Content Security Policy that restricts style-src to 'self' and a modern SPA will break on first paint: CSS-in-JS runtime injections, the <style> tags React mounts for styled-components or Emotion, and inline style attributes all get rejected by the browser. Reach for style-src 'unsafe-inline' and you've closed nothing, because the attacker's injected <style> tag passes through the exact same hole. Nonces and hashes give you a middle path that keeps runtime styling working while still rejecting untrusted CSS.
Why default CSP blocks inline styles
The style-src directive governs three distinct mechanisms: external stylesheets referenced by <link>, inline <style> blocks, and style attributes on individual elements. When a policy sets style-src 'self' or any other source list that omits 'unsafe-inline', all inline styles are rejected. The rationale is straightforward: inline CSS is an injection surface. An attacker who finds a way to inject markup can add a <style> block that targets your selectors and either defaces the page or exfiltrates data — attribute selectors can reach password inputs, and background: url(...) can leak their values to a third-party server. The style attribute is arguably worse: it lets an attacker hide or obscure an element with a single property.
Modern SPAs are structurally dependent on inline styles. CSS-in-JS libraries like styled-components and Emotion generate <style> tags at runtime and inject them into <head>. React's style prop becomes a style attribute on the DOM node. Dynamic theming — swapping a color variable mid-session — is almost always implemented as either a CSS custom property mutation or a freshly mounted <style> tag. These frameworks do offer build-time extraction of static styles, but anything generated after the first render escapes it.
The tempting fix is style-src 'unsafe-inline'. It restores everything: style blocks, attributes, all of it. But 'unsafe-inline' is the same mechanism that lets attacker-injected CSS through. It is the equivalent of omitting style-src from the policy altogether, and if you are collecting violation reports, 'unsafe-inline' is indistinguishable from having no policy at all — the directive stops producing violations because it stops being a filter.
Nonce-based approach: how it works
A nonce is a per-request random value generated on the server and reflected in two places: the CSP header and every inline style element the server sends. The browser allows an inline <style> block or style attribute only if its nonce attribute matches a token listed in style-src. Anything without the nonce — including attacker-injected markup — is rejected.
For a CSS-in-JS stack, this works when the library accepts a nonce prop. styled-components' StyleSheetManager and Emotion's cache configuration both support passing a nonce through their providers. On the server, you generate the token from a cryptographic source — crypto.randomBytes, not Math.random — render the tree, and set the nonce attribute on the emitted <style> tags. The same value goes into the CSP header. The style-src reference on MDN spells out the exact matching rules: the token must match exactly, byte for byte.
The hard constraint is that nonces must be generated at request time. A static SPA — a directory of pre-built HTML, JS, and CSS files sitting on a CDN — cannot produce per-request nonces without an edge function rewriting the HTML on the way out. If you deploy to a purely static host, you either accept a fixed nonce embedded at build time, which is really just a shared secret that leaks the moment anyone reads your HTML source, or you switch to the hash approach.
Nonces also interact awkwardly with streaming. A nonce must be decided before the first byte of HTML streams to the client, because the browser needs it in the header before it parses any markup. That is usually fine — you generate it in middleware before the render begins — but if you are streaming a Suspense tree, the token has to exist before you flush anything. Streaming SSR with Suspense covers what reaches the browser first when you are working under this kind of constraint.
Hash-based approach: precomputed integrity
Instead of a random token, a hash-based policy lists the cryptographic digest of each allowed inline style block. You compute SHA-256 (or SHA-384/512) of the exact bytes of a <style> block, base64-encode the digest, and list it in style-src as 'sha256-...'. The browser computes the same digest from the served content and allows it only if it matches an entry in the policy.
For a static SPA this is attractive. The hashes are deterministic for a given source tree, so you can compute them at build time and bake the resulting CSP header into your HTML or your host's configuration. No request-time generation, no edge functions. Any CSS-in-JS library that emits a deterministic <style> block from the same input produces identical bytes on every build — until you change the CSS, at which point the hash changes.
The downside is the regeneration cycle. A component tweak that alters a single style block invalidates that block's hash, and the policy must be regenerated before the new bundle deploys. If you ship frequently, you want a small script in the build pipeline that scans the emitted CSS chunks and rebuilds the header. If your CSS is split into many chunks that are deployed independently, this becomes a genuine operational burden. The alternative — a dynamic endpoint that serves the current header — defeats the static-host simplicity that hash-based CSP is designed to preserve.
Here is a build script that computes the hashes for generated CSS files:
// scripts/csp-hashes.mjs
import { readFile, readdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import { URL } from "node:url";
const assetsDir = new URL("../dist/assets/", import.meta.url);
const files = (await readdir(assetsDir)).filter((f) => f.endsWith(".css"));
const hashes = [];
for (const file of files) {
const css = await readFile(new URL(file, assetsDir), "utf8");
if (!css.includes("/* injected */")) continue; // only your styled blocks
const digest = createHash("sha256").update(css).digest("base64");
hashes.push(`'sha256-${digest}'`);
}
console.log(`style-src 'self' ${hashes.join(" ")}`);One mechanical detail: the hash has to cover exactly the bytes the browser sees inside the <style> block — whitespace, newlines, everything. A formatter that adds a trailing newline changes the hash, and the browser will reject a block whose digest no longer matches.
Strict-dynamic and the delegation chain
'strict-dynamic' is technically a script-src keyword, but it intersects with styles in a way worth understanding. It relaxes script loading only: scripts loaded by a trusted script — one carrying a valid nonce or hash — are allowed to load further scripts without being listed individually. It does nothing to loosen style-src. You still need a nonce or hash in style-src before any inline style is allowed, and the CSP3 specification is explicit that strict-dynamic has no direct effect on style directives.
What strict-dynamic does for you is simplify the source list. With strict-dynamic in script-src, you no longer need to enumerate every third-party origin for scripts, and the same delegation chain can apply to styles: a nonced <style> tag can reference an external stylesheet from any origin without that origin appearing in style-src. In practice the chain is: the CSP trusts the nonced <style> tag; that tag loads an external stylesheet; the browser does not require a separate entry for the stylesheet's origin.
The caveat is browser support. Legacy user agents treat strict-dynamic as an unknown keyword and ignore it, which means any source list that relies on it silently collapses. You ship a policy with both: a fallback style-src that lists specific origins for older browsers, and a nonce-plus-strict-dynamic policy for modern ones. Browsers that understand strict-dynamic ignore the fallback host list and use only the nonce and hash entries; older browsers ignore strict-dynamic and apply the fallback. The script-src MDN entry describes the fallback interaction in detail.
Configuration examples: Vite and Next.js
Vite exposes a cspNonce setting on the server options. When set, Vite injects the token as the nonce attribute on the <style> tags it emits during development, and you reference the same token in your CSP header. The value is generated in your config and is the same for every request served by the dev server, which makes it a development convenience rather than a production security barrier — in production you either do a build-time replacement or serve the static output behind a CDN worker that rewrites the nonce per request.
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
server: {
cspNonce: "dev-nonce-placeholder",
},
});In your index.html template, the placeholder appears on the tag you want covered:
<style nonce="__NONCE__">/* dev styles */</style>and the header has the matching token:
Content-Security-Policy: style-src 'self' 'nonce-__NONCE__'In production, a CDN edge worker replaces __NONCE__ with a fresh value per request, or you drop the nonce approach entirely and use build-time hashes.
Next.js App Router handles this in middleware. Generate a nonce per request, set the header, and thread the same value through to your CSS-in-JS provider.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
export function middleware(req: NextRequest) {
const nonce = randomBytes(16).toString("base64");
const res = NextResponse.next();
res.headers.set(
"Content-Security-Policy",
`style-src 'self' 'nonce-${nonce}' script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
);
return res;
}Then in the root layout, pass the nonce to the styled-components provider:
// app/layout.tsx (simplified)
import { StyleSheetManager } from "styled-components";
export default function RootLayout({
children,
nonce,
}: {
children: React.ReactNode;
nonce: string;
}) {
return (
<StyleSheetManager nonce={nonce}>
<html lang="en">
<body>{children}</body>
</html>
</StyleSheetManager>
);
}The header must be set server-side or at the CDN. A <meta http-equiv="Content-Security-Policy"> tag in the HTML cannot carry a per-request nonce — the token would be static, visible in source, and effectively a shared secret — so header-based delivery is the only sane route for nonce policies.
Gotchas: report-only, dynamic styles, and extensions
Start in report-only mode, always. Content-Security-Policy-Report-Only emits violation reports without blocking anything. Point it at a reporting endpoint and let it run against real traffic for a few days. Static SPAs generate a burst of reports on first deploy, usually from third-party scripts, extension styles, and framework internals you forgot about. Triaging those before flipping the enforcement flag is far cheaper than debugging a UI that renders blank in production.
Style attributes are the trickiest case. Setting element.style.color in JavaScript triggers a style-attr violation, and CSP has no way to hash a style attribute — hashes cover <style> blocks only. Your options are to use CSS custom properties and mutate the property value instead of the element's style object, or to allow 'unsafe-hashes' for specific attributes, which is a blunt instrument that weakens the whole directive. Custom properties are the cleaner pattern; hand-written imperative styling will otherwise trip the policy continuously, and the fix is usually to move the mutable values into --custom-properties on the parent container.
Browser extensions will appear in your violation reports constantly. Ad blockers, dark-mode toggles, and password managers all inject <style> blocks and style attributes. You cannot whitelist extension origins by name, so you either accept a permanent noise floor in reports or filter by origin in your reporting service. Do not add 'unsafe-inline' to silence them.
If you find yourself fighting the runtime styling problem from every angle, it is worth asking whether the runtime is necessary at all. Tailwind CSS v4 without a config file describes a styling pipeline where everything is static and nothing depends on runtime injection. And if you are already auditing how your CSS is delivered and applied, CSS Container Queries in Production covers a related area where the boundaries between static and dynamic styling get murky.
| Aspect | Nonce | Hash |
|---|---|---|
| Generation point | Per request, server-side | Build time, deterministic |
| Static SPA support | Requires edge function or SSR | Native — baked into header at build |
| Change handling | New token every request, no rebuild | Recompute hash on every CSS change |
| Security property | Unpredictable per-request token | Cryptographic digest of content |
| Tooling support | styled-components, Emotion, Vite | Custom build script, manual policy |
Key takeaways
unsafe-inlineinstyle-srcdisables the style protection entirely — use a nonce or hash instead of reaching for it.- Nonces require per-request generation; hashes are deterministic and the right fit for fully static SPAs, at the cost of regenerating on every CSS change.
strict-dynamicrelaxesscript-srconly; styles still need a nonce or hash entry instyle-src.- Deploy with
Content-Security-Policy-Report-Onlyand a reporting endpoint first, and expect a baseline of violation noise from browser extensions. - Style attributes cannot be hashed as blocks can — use CSS custom properties for imperative styling mutations.
Frequently asked questions
- Can I use CSP with CSS-in-JS libraries like styled-components?
- Yes. Both styled-components and Emotion accept a `nonce` prop that gets applied to generated <style> tags. Pass the same nonce from your server response into the provider component, and list it in your CSP style-src directive.
- What happens if my CSP blocks an inline style — will the page break?
- The browser silently drops the blocked style rule; elements will render without that styling. This often causes visible layout or visual defects. Use Content-Security-Policy-Report-Only first to detect violations without breaking the UI.
- Is there a way to allow inline styles without a nonce or hash?
- The only safe alternative is to move all inline styles to external stylesheets. For static SPAs, this means using CSS modules or generated class names. 'unsafe-inline' removes protection entirely and is not recommended for production.


