Skip to content

React Security Best Practices: CSP Nonces and the Style I Couldn’t Lock Down

React Security Best Practices: CSP Nonces and the Style I Couldn’t Lock Down

Confession: for about three years, every React app I shipped had a Content-Security-Policy header that I’d copied from a Stack Overflow answer, and the second directive in it was 'unsafe-inline'. I knew that was the directive that switches off most of what CSP is for. I told myself I’d fix it after launch. Launch happened. I did not fix it.

What finally made me do it was reading the pgAdmin 4 v9.18 release notes last week. pgAdmin is a React and MUI app, like half of what I build, and the changelog describes exactly the migration I’d been avoiding: inline scripts now run under a per-request nonce instead of a blanket 'unsafe-inline', and 'unsafe-eval' is gone. Then comes the line that made me laugh, because it’s the wall I’d hit every time I tried: style-src still keeps 'unsafe-inline', “because MUI and React inject runtime styles and inline style attributes that cannot carry a nonce”.

So the pgAdmin team, who have far more reason to care about this than I do, got scripts locked down and gave up on styles. That reframed the job for me. I’d been treating CSP as all-or-nothing and shipping nothing. It’s a two-part problem and one part is much easier than the other.

What a nonce actually buys you

A quick recap so the rest makes sense. CSP is a response header that tells the browser which sources of script and style it’s allowed to run. The trouble with React apps is that the build output usually has at least one inline <script> in index.html, and CSS-in-JS libraries inject <style> tags at runtime. The lazy way to allow those is 'unsafe-inline', which allows every inline script, including the one an attacker managed to inject through an unescaped field.

A nonce is the alternative. Your server generates a random value per request, puts it in the header as 'nonce-abc123', and stamps the same value on every <script> tag it intends to run. Anything inline without that exact nonce is blocked. Injected script has no way to guess it, because it’s different on every response. That’s the whole trick, and it’s why the MDN CSP reference is so insistent that the nonce be unpredictable and generated fresh per response.

Here’s the header I’d been shipping. If yours looks like this, we were in the same club.

Content-Security-Policy: default-src 'self';
  script-src 'self' 'unsafe-inline' 'unsafe-eval';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;

And here’s roughly where I’ve ended up, which I’ll build up piece by piece below.

Content-Security-Policy: default-src 'self';
  script-src 'self' 'nonce-${NONCE}';
  style-src-elem 'self' 'nonce-${NONCE}';
  style-src-attr 'unsafe-inline';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'self';

The interesting line is the style-src-attr one. I’ll get to why it’s still there, because it took me an embarrassing amount of time to understand.

Scripts: the part that’s easy once you find the Vite option

The reason I kept giving up in the past is that I assumed I’d need to write a plugin to thread a nonce through the build. I didn’t. Vite has a config option for it, html.cspNonce, and when it’s set Vite adds a nonce attribute to every <script>, <style> and stylesheet <link> it emits, plus a <meta property="csp-nonce"> tag it uses internally for anything it injects later.

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  html: {
    cspNonce: "__CSP_NONCE__",
  },
});

The value is a placeholder, not a real nonce. The build is static, so it can’t know the per-request value. Your server has to replace the placeholder on every response. This is the step the Vite docs put a warning box around, and rightly: if you ship the placeholder as the real nonce, it’s a constant, an attacker can read it out of the page, and you’ve built an elaborate 'unsafe-inline'.

I do the replacement in the reverse proxy. Caddy makes it short.

example.com {
  root * /srv/app/dist
  file_server

  @html path / /index.html
  handle @html {
    header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-{http.request.uuid}'; style-src-elem 'self' 'nonce-{http.request.uuid}'; style-src-attr 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'"
    templates
  }
}

That’s a sketch, not a drop-in. Caddy’s templates directive can do string replacement on the HTML body, and {http.request.uuid} gives you a per-request random value. In practice I ended up with a tiny Go handler in front of the static files because I wanted the nonce generated with crypto/rand and the substitution done in one place I could unit test. Either way the shape is the same: one random value, written into the header and into the HTML, on every request.

If you’ve read my post on what this blog actually runs on you’ll know I run Caddy on this box, and the nonce handling is one of the reasons I haven’t gone back. Doing the same in Nginx means sub_filter plus a $request_id variable, which works but reads like a ransom note.

Dropping 'unsafe-eval' was free. Nothing in my production bundles needed it. pgAdmin’s notes mention they re-add it automatically for development bundles when DEBUG is set, and I copied that idea: my dev server config has a looser header and the production one doesn’t. The mistake I’d been making was using one header for both and letting dev requirements leak into prod.

Styles: where I learned what style-src-attr means

This is the part where I got stuck, and where pgAdmin’s changelog was more honest than most of the tutorials I’d read.

MUI uses Emotion to inject <style> elements at runtime. Those can carry a nonce. Emotion’s cache accepts one, and MUI’s CSP guide shows the setup. You read the nonce from the meta tag Vite emitted and hand it to the cache.

// main.tsx
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";

const nonce =
  document.querySelector<HTMLMetaElement>('meta[property="csp-nonce"]')
    ?.nonce ?? "";

const cache = createCache({ key: "mui", nonce });

createRoot(document.getElementById("root")!).render(
  <CacheProvider value={cache}>
    <App />
  </CacheProvider>
);

I did that, reloaded, and the console still filled with CSP violations. Every one of them was a style attribute, not a <style> element. That’s the distinction I hadn’t understood. CSP level 3 splits style-src into style-src-elem (for <style> tags and stylesheet links) and style-src-attr (for style="..." attributes on elements). A nonce can only be attached to an element. There is no place to put one on an attribute. So any component that renders style={{ width: 240 }}, which is a lot of MUI internals and a lot of my own code, produces an inline style attribute that no nonce can ever authorise.

MUI’s guide says this outright: style-src-elem takes the nonce, style-src-attr needs 'unsafe-inline' because some components set inline styles for dynamic values like dimensions and positioning. pgAdmin’s changelog says the same thing in fewer words. I’d spent two evenings trying to make a nonce do something the spec doesn’t allow.

The honest options for attributes are 'unsafe-inline' scoped to style-src-attr only, or 'unsafe-hashes' with a hash of every inline style value your app can produce, which is not realistic when the values are computed at runtime. I took the first option. It’s a much smaller hole than the one I started with: an attacker who can inject markup can set inline styles, which enables some ugly UI-redressing tricks, but they cannot run script, which is the thing I actually lie awake about.

The violations I found that weren’t mine

Once the header was strict, the browser started reporting things I’d never have found by reading my own code. Two stood out.

A third-party analytics snippet I’d pasted into index.html a year ago was an inline script with no nonce. Blocked. I moved it to an external file served from my own origin with a nonce on the tag, and while I was in there I noticed it was loading a second script from a domain I’d never heard of. That got removed rather than nonced.

A small chart component was using new Function() to evaluate a formatter string from its props. That’s an eval in a trench coat and it had been sitting in the bundle for months. Dropping 'unsafe-eval' surfaced it immediately. I rewrote it as a plain function map, which is what it should have been.

Neither of these was an actual exploit. But both were exactly the class of thing a strict policy is meant to catch, and the only reason I found them is that the policy stopped being decorative. I’ve done security audits on Laravel apps where the CSP was the first thing I checked, and I’d been quietly failing my own checklist on the React side.

Rolling it out without breaking production

I did not flip the strict header on for everyone at once. Content-Security-Policy-Report-Only exists for exactly this. It evaluates the policy, reports violations to an endpoint you nominate, and blocks nothing. I ran it for a week with a report-to pointing at a tiny endpoint that appends to a log file, then read the log.

The log was noisy for two days, mostly from browser extensions injecting their own scripts, which show up as violations you can’t do anything about. After filtering those out, the real list was the two items above plus a handful of inline event handlers in old code. Then I switched the header from report-only to enforcing, kept the report endpoint, and moved on.

I’m still not certain I’ve got img-src right. I allow data: because Vite inlines small assets as data URIs by default, and the Vite docs suggest either allowing it or setting build.assetsInlineLimit to zero. I went with allowing it. I might change my mind.

What to do this week

Add Content-Security-Policy-Report-Only to one React app you run, with script-src 'self' 'nonce-...' and no 'unsafe-inline' in it, point report-to at any endpoint that writes to a file, and leave it for a few days. Don’t fix anything yet. Just read the report. Mine had two things in it that I’d been shipping for a year without knowing, and finding them cost nothing but the time to read a log. I do this kind of hardening on client apps through my consulting work, and the report-only week is always where the interesting findings come from.