{"id":715,"date":"2026-09-23T05:01:06","date_gmt":"2026-09-23T05:01:06","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-security-best-practices-csp-nonce-the-style-attribute-i-couldnt-lock-down\/"},"modified":"2026-09-23T05:01:06","modified_gmt":"2026-09-23T05:01:06","slug":"react-security-best-practices-csp-nonce-the-style-attribute-i-couldnt-lock-down","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-security-best-practices-csp-nonce-the-style-attribute-i-couldnt-lock-down\/","title":{"rendered":"React Security Best Practices: CSP Nonces and the Style I Couldn&#8217;t Lock Down"},"content":{"rendered":"<p>Confession: for about three years, every React app I shipped had a Content-Security-Policy header that I&rsquo;d copied from a Stack Overflow answer, and the second directive in it was <code>'unsafe-inline'<\/code>. I knew that was the directive that switches off most of what CSP is for. I told myself I&rsquo;d fix it after launch. Launch happened. I did not fix it.<\/p>\n<p>What finally made me do it was reading the <a href=\"https:\/\/www.postgresql.org\/about\/news\/pgadmin-4-v918-released-3381\/\" rel=\"nofollow noopener\" target=\"_blank\">pgAdmin 4 v9.18 release notes<\/a> last week. pgAdmin is a React and MUI app, like half of what I build, and the changelog describes exactly the migration I&rsquo;d been avoiding: inline scripts now run under a per-request nonce instead of a blanket <code>'unsafe-inline'<\/code>, and <code>'unsafe-eval'<\/code> is gone. Then comes the line that made me laugh, because it&rsquo;s the wall I&rsquo;d hit every time I tried: <code>style-src<\/code> still keeps <code>'unsafe-inline'<\/code>, &ldquo;because MUI and React inject runtime styles and inline style attributes that cannot carry a nonce&rdquo;.<\/p>\n<p>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&rsquo;d been treating CSP as all-or-nothing and shipping nothing. It&rsquo;s a two-part problem and one part is much easier than the other.<\/p>\n<h2 id=\"what-a-nonce-actually-buys-you\">What a nonce actually buys you<\/h2>\n<p>A quick recap so the rest makes sense. CSP is a response header that tells the browser which sources of script and style it&rsquo;s allowed to run. The trouble with React apps is that the build output usually has at least one inline <code>&lt;script&gt;<\/code> in <code>index.html<\/code>, and CSS-in-JS libraries inject <code>&lt;style&gt;<\/code> tags at runtime. The lazy way to allow those is <code>'unsafe-inline'<\/code>, which allows every inline script, including the one an attacker managed to inject through an unescaped field.<\/p>\n<p>A nonce is the alternative. Your server generates a random value per request, puts it in the header as <code>'nonce-abc123'<\/code>, and stamps the same value on every <code>&lt;script&gt;<\/code> tag it intends to run. Anything inline without that exact nonce is blocked. Injected script has no way to guess it, because it&rsquo;s different on every response. That&rsquo;s the whole trick, and it&rsquo;s why the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/HTTP\/Reference\/Headers\/Content-Security-Policy\" rel=\"nofollow noopener\" target=\"_blank\">MDN CSP reference<\/a> is so insistent that the nonce be unpredictable and generated fresh per response.<\/p>\n<p>Here&rsquo;s the header I&rsquo;d been shipping. If yours looks like this, we were in the same club.<\/p>\n<pre><code class=\"language-http\">Content-Security-Policy: default-src 'self';\n  script-src 'self' 'unsafe-inline' 'unsafe-eval';\n  style-src 'self' 'unsafe-inline';\n  img-src 'self' data:;\n<\/code><\/pre>\n<p>And here&rsquo;s roughly where I&rsquo;ve ended up, which I&rsquo;ll build up piece by piece below.<\/p>\n<pre><code class=\"language-http\">Content-Security-Policy: default-src 'self';\n  script-src 'self' 'nonce-${NONCE}';\n  style-src-elem 'self' 'nonce-${NONCE}';\n  style-src-attr 'unsafe-inline';\n  img-src 'self' data:;\n  object-src 'none';\n  base-uri 'self';\n<\/code><\/pre>\n<p>The interesting line is the <code>style-src-attr<\/code> one. I&rsquo;ll get to why it&rsquo;s still there, because it took me an embarrassing amount of time to understand.<\/p>\n<h2 id=\"scripts-the-part-thats-easy-once-you-find-the-vite-option\">Scripts: the part that&rsquo;s easy once you find the Vite option<\/h2>\n<p>The reason I kept giving up in the past is that I assumed I&rsquo;d need to write a plugin to thread a nonce through the build. I didn&rsquo;t. Vite has a config option for it, <a href=\"https:\/\/vite.dev\/guide\/features#content-security-policy-csp\" rel=\"nofollow noopener\" target=\"_blank\"><code>html.cspNonce<\/code><\/a>, and when it&rsquo;s set Vite adds a <code>nonce<\/code> attribute to every <code>&lt;script&gt;<\/code>, <code>&lt;style&gt;<\/code> and stylesheet <code>&lt;link&gt;<\/code> it emits, plus a <code>&lt;meta property=\"csp-nonce\"&gt;<\/code> tag it uses internally for anything it injects later.<\/p>\n<pre><code class=\"language-ts\">\/\/ vite.config.ts\nimport { defineConfig } from &quot;vite&quot;;\nimport react from &quot;@vitejs\/plugin-react&quot;;\n\nexport default defineConfig({\n  plugins: [react()],\n  html: {\n    cspNonce: &quot;__CSP_NONCE__&quot;,\n  },\n});\n<\/code><\/pre>\n<p>The value is a placeholder, not a real nonce. The build is static, so it can&rsquo;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&rsquo;s a constant, an attacker can read it out of the page, and you&rsquo;ve built an elaborate <code>'unsafe-inline'<\/code>.<\/p>\n<p>I do the replacement in the reverse proxy. Caddy makes it short.<\/p>\n<pre><code class=\"language-caddyfile\">example.com {\n  root * \/srv\/app\/dist\n  file_server\n\n  @html path \/ \/index.html\n  handle @html {\n    header Content-Security-Policy &quot;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'&quot;\n    templates\n  }\n}\n<\/code><\/pre>\n<p>That&rsquo;s a sketch, not a drop-in. Caddy&rsquo;s <code>templates<\/code> directive can do string replacement on the HTML body, and <code>{http.request.uuid}<\/code> 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 <code>crypto\/rand<\/code> 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.<\/p>\n<p>If you&rsquo;ve read my post on <a href=\"https:\/\/abrarqasim.com\/blog\/hetzner-vs-digitalocean-what-this-blog-actually-runs-on\/\" rel=\"noopener\">what this blog actually runs on<\/a> you&rsquo;ll know I run Caddy on this box, and the nonce handling is one of the reasons I haven&rsquo;t gone back. Doing the same in Nginx means <code>sub_filter<\/code> plus a <code>$request_id<\/code> variable, which works but reads like a ransom note.<\/p>\n<p>Dropping <code>'unsafe-eval'<\/code> was free. Nothing in my production bundles needed it. pgAdmin&rsquo;s notes mention they re-add it automatically for development bundles when <code>DEBUG<\/code> is set, and I copied that idea: my dev server config has a looser header and the production one doesn&rsquo;t. The mistake I&rsquo;d been making was using one header for both and letting dev requirements leak into prod.<\/p>\n<h2 id=\"styles-where-i-learned-what-style-src-attr-means\">Styles: where I learned what style-src-attr means<\/h2>\n<p>This is the part where I got stuck, and where pgAdmin&rsquo;s changelog was more honest than most of the tutorials I&rsquo;d read.<\/p>\n<p>MUI uses Emotion to inject <code>&lt;style&gt;<\/code> elements at runtime. Those can carry a nonce. Emotion&rsquo;s cache accepts one, and <a href=\"https:\/\/mui.com\/material-ui\/guides\/content-security-policy\/\" rel=\"nofollow noopener\" target=\"_blank\">MUI&rsquo;s CSP guide<\/a> shows the setup. You read the nonce from the meta tag Vite emitted and hand it to the cache.<\/p>\n<pre><code class=\"language-tsx\">\/\/ main.tsx\nimport createCache from &quot;@emotion\/cache&quot;;\nimport { CacheProvider } from &quot;@emotion\/react&quot;;\n\nconst nonce =\n  document.querySelector&lt;HTMLMetaElement&gt;('meta[property=&quot;csp-nonce&quot;]')\n    ?.nonce ?? &quot;&quot;;\n\nconst cache = createCache({ key: &quot;mui&quot;, nonce });\n\ncreateRoot(document.getElementById(&quot;root&quot;)!).render(\n  &lt;CacheProvider value={cache}&gt;\n    &lt;App \/&gt;\n  &lt;\/CacheProvider&gt;\n);\n<\/code><\/pre>\n<p>I did that, reloaded, and the console still filled with CSP violations. Every one of them was a <code>style<\/code> attribute, not a <code>&lt;style&gt;<\/code> element. That&rsquo;s the distinction I hadn&rsquo;t understood. CSP level 3 splits <code>style-src<\/code> into <code>style-src-elem<\/code> (for <code>&lt;style&gt;<\/code> tags and stylesheet links) and <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/HTTP\/Reference\/Headers\/Content-Security-Policy\/style-src-attr\" rel=\"nofollow noopener\" target=\"_blank\"><code>style-src-attr<\/code><\/a> (for <code>style=\"...\"<\/code> 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 <code>style={{ width: 240 }}<\/code>, 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.<\/p>\n<p>MUI&rsquo;s guide says this outright: <code>style-src-elem<\/code> takes the nonce, <code>style-src-attr<\/code> needs <code>'unsafe-inline'<\/code> because some components set inline styles for dynamic values like dimensions and positioning. pgAdmin&rsquo;s changelog says the same thing in fewer words. I&rsquo;d spent two evenings trying to make a nonce do something the spec doesn&rsquo;t allow.<\/p>\n<p>The honest options for attributes are <code>'unsafe-inline'<\/code> scoped to <code>style-src-attr<\/code> only, or <code>'unsafe-hashes'<\/code> 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&rsquo;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.<\/p>\n<h2 id=\"the-violations-i-found-that-werent-mine\">The violations I found that weren&rsquo;t mine<\/h2>\n<p>Once the header was strict, the browser started reporting things I&rsquo;d never have found by reading my own code. Two stood out.<\/p>\n<p>A third-party analytics snippet I&rsquo;d pasted into <code>index.html<\/code> 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&rsquo;d never heard of. That got removed rather than nonced.<\/p>\n<p>A small chart component was using <code>new Function()<\/code> to evaluate a formatter string from its props. That&rsquo;s an <code>eval<\/code> in a trench coat and it had been sitting in the bundle for months. Dropping <code>'unsafe-eval'<\/code> surfaced it immediately. I rewrote it as a plain function map, which is what it should have been.<\/p>\n<p>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&rsquo;ve done <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-security-audit-with-the-agent-you-already-use\/\" rel=\"noopener\">security audits on Laravel apps<\/a> where the CSP was the first thing I checked, and I&rsquo;d been quietly failing my own checklist on the React side.<\/p>\n<h2 id=\"rolling-it-out-without-breaking-production\">Rolling it out without breaking production<\/h2>\n<p>I did not flip the strict header on for everyone at once. <code>Content-Security-Policy-Report-Only<\/code> 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 <code>report-to<\/code> pointing at a tiny endpoint that appends to a log file, then read the log.<\/p>\n<p>The log was noisy for two days, mostly from browser extensions injecting their own scripts, which show up as violations you can&rsquo;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.<\/p>\n<p>I&rsquo;m still not certain I&rsquo;ve got <code>img-src<\/code> right. I allow <code>data:<\/code> because Vite inlines small assets as data URIs by default, and the Vite docs suggest either allowing it or setting <code>build.assetsInlineLimit<\/code> to zero. I went with allowing it. I might change my mind.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Add <code>Content-Security-Policy-Report-Only<\/code> to one React app you run, with <code>script-src 'self' 'nonce-...'<\/code> and no <code>'unsafe-inline'<\/code> in it, point <code>report-to<\/code> at any endpoint that writes to a file, and leave it for a few days. Don&rsquo;t fix anything yet. Just read the report. Mine had two things in it that I&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/\" rel=\"noopener\">my consulting work<\/a>, and the report-only week is always where the interesting findings come from.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>pgAdmin 9.18 moved inline scripts to a per-request nonce but kept &#8216;unsafe-inline&#8217; for styles. Here&#8217;s why, and how I did the same on a Vite and MUI app.<\/p>\n","protected":false},"author":2,"featured_media":714,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"pgAdmin 9.18 moved inline scripts to a per-request nonce but kept 'unsafe-inline' for styles. Here's why, and how I did the same on a Vite and MUI app.","rank_math_focus_keyword":"react security best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[354,151],"tags":[799,800,801,798,802,200],"class_list":["post-715","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-react","category-security","tag-content-security-policy","tag-csp-nonce","tag-mui","tag-react-security","tag-unsafe-inline","tag-vite"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/715","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=715"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/715\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/714"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=715"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=715"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=715"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}