chevron-left chevron-right

Why do my tables collapse into one column? Strict CSP vs inline styles in React

Nowadays a Content Security Policy is one of the first things a security audit checks in a web application. It is a great protection against cross-site scripting, but it can also break your app in a way that is really hard to notice.

Some time ago I was dealing with a bug report that sounded simple: a table looks fine when you get to it from the menu, but after a page refresh it collapses into one long column. The fix itself is not complicated. The interesting part is why it happened at all, and that is what I'm going to describe in this article.

To show everything step by step, I built a small demo app (Next.js 16, React 19, Chrome 154). All the results you will see below come from running it, so you can check them yourself: github.com/sunpietro/csp-issue-demo.

Background

The project had just received a strict, nonce-based policy, after a security audit pointed out that the app was not sending any Content-Security-Policy header. The policy was very similar to the one from the Next.js guide:

default-src 'self';
script-src 'self' 'nonce-RANDOM' 'strict-dynamic';
style-src 'self' 'nonce-RANDOM';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';

The tables in the app were CSS grids, and each one received its column layout as a prop:

<div role="table" className="grid" style={{ gridTemplateColumns: columns }}>

Looks innocent, right? Keep this piece of code in mind, we will get back to it.

What is a Content Security Policy?

In short, a CSP is a list of rules that the server sends together with the page, in an HTTP header. The rules tell the browser what the page is allowed to load and run. Anything that is not on the list gets blocked, even if it sits right there in the HTML. Thanks to that, if an attacker manages to inject some markup into your page, most of it won't do anything.

A nonce is a random value generated for every request. The server puts it into the policy and stamps the same value on its own <script> and <style> tags. The browser runs only those tags whose nonce matches. If you want to read more about the directives, MDN explains them really well.

There is one thing in the policy above that is easy to miss. It doesn't mention style attributes at all, and it doesn't have to. The style-src-attr directive, which is responsible for style="..." attributes, falls back to style-src when it is not set. And style-src has no 'unsafe-inline'. It means that the browser refuses every style attribute sent by the server.

Three ways to style an element

An element can get its styles in one of three ways:

  1. A stylesheet, for example <link rel="stylesheet" href="/app.css">, or the CSS generated by your bundler. The policy allows it thanks to style-src 'self',
  2. A <style> tag inside the page. Allowed only if it carries the nonce,
  3. A style attribute on a single element. React writes one whenever it renders a style prop on the server.

To check what exactly gets blocked, I prepared the /experiment page in the demo. It tries seven different variations and reads back what the browser actually applied:

How the style reaches the element Result
A style attribute in the HTML sent by the server Blocked
A <style> tag with the nonce Applied
A <style> tag without a nonce Blocked
A <link rel="stylesheet"> to a file on the same origin Applied
A trusted script sets element.style.backgroundColor Applied
A trusted script calls setAttribute('style', ...) Blocked
A React style prop on an element created in the browser Applied

For the blocked attribute, Chrome shows the following message in the console:

Applying inline style violates the following Content Security Policy directive 'style-src 'self' 'nonce-...''. Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.

Be careful with this message, because it suggests that a nonce can solve the problem. For attributes it can't. In the CSP specification a nonce belongs to an element, such as <script> or <style>, and an attribute simply has nowhere to carry it. The only ways to allow style attributes are 'unsafe-inline', which allows all of them, or a hash of every allowed value combined with 'unsafe-hashes'.

Now take a look at the fifth and the last row. A trusted script that sets element.style is not blocked. This is the AHA moment of this article: CSP checks styles that come as text to parse, an attribute in the HTML or setAttribute with a string. Changing a property on the element's style object goes through the CSS Object Model (CSSOM), and the policy doesn't cover that path at all.

Why only after a refresh?

Let's go through what happens on a fresh page load:

  1. The server renders the table and sends <div class="grid" style="grid-template-columns: ...">,
  2. The browser parses the HTML, checks the policy and refuses the attribute. The grid has no column definition now, so it puts every cell in a single column,
  3. React hydrates the page. It takes over the DOM sent by the server instead of building it again. From React's point of view the style attribute is already there, so it doesn't set it for the second time. The refused style stays refused.

On a client-side navigation it looks completely different. No HTML comes from the server. React creates the table in the browser and applies the style prop through the element's style object, which is exactly the CSSOM path from the last row of the experiment. The policy allows it.

The same component, the same props, the same policy. Broken after a refresh, working fine after a click. No wonder it was confusing.

Why didn't anybody notice it?

Because the policy was switched off everywhere developers worked and tests ran. It is a very common setup and, to be honest, the official guide nudges you towards it. Its example for the development environment relaxes the style rule to style-src 'self' 'unsafe-inline', which lets every style attribute through.

The demo can run the same production build in three modes, so you can see the difference on the broken page:

  • CSP_MODE=off: 4 columns on a fresh load, 4 columns on a navigation, no CSP messages. Everything looks fine,
  • CSP_MODE=report-only: also 4 columns everywhere, but there is one CSP message in the console after a fresh load,
  • CSP_MODE=enforce: 1 column on a fresh load, 4 columns on a navigation, and the same CSP message.

With the policy switched off there is simply nothing to find. The report-only mode is a really useful middle ground: the page still works, and the browser tells you exactly what the enforced policy would block. IMO it is the best way to introduce a new policy.

But the real fix is a test that runs where the policy is real: a production build, the policy enforced, and a check that fails on any CSP message. In Playwright it takes just a few lines:

function collectCspViolations(page: Page): string[] {
  const violations: string[] = []

  page.on('console', message => {
    if (message.text().includes('Content Security Policy')) {
      violations.push(message.text())
    }
  })

  return violations
}

Make sure to test both paths, a fresh load and a client-side navigation. As you will see in a moment, a fix can pass one of them and fail the other.

How to fix it?

I checked four different fixes in the demo. Here is the summary:

Fix Fresh load Client-side navigation What it costs
1. A static class Works Works Only for layouts known at build time
2. Setting the value through the CSSOM Works Works The table is invisible until JavaScript runs
3. A <style> tag with the nonce Works Breaks The nonce changes with every request
4. A stylesheet from your own origin Works Works An extra request, and input you must validate

Fix 1: a static class

If you know all the layouts at build time, just put them into a stylesheet:

.cols-packages {
  grid-template-columns: minmax(12rem, 2fr) 7rem 9rem 6rem;
}

This is the best option whenever it fits. If you use Tailwind, arbitrary values like grid-cols-[minmax(12rem,2fr)_7rem] work too, but only when that exact class name is written somewhere in your source code. Tailwind generates CSS from the class names it finds at build time, so a layout that comes from a prop is invisible to it.

Fix 2: setting the value through the CSSOM

Since the policy ignores the CSSOM, a client component can set the value after it mounts:

useLayoutEffect(() => {
  const element = ref.current

  if (!element) {
    return
  }

  element.style.gridTemplateColumns = columns
  element.classList.remove('cssom-pending')
}, [columns])

It works on both paths. The downside is that the table rendered on the server has no layout until hydration, so the demo hides it until then. Instead of a broken table you get an invisible one for a moment, which can delay your Largest Contentful Paint. And without JavaScript the table never shows up. It's fine for a tooltip, but not for the main content of a page.

Fix 3: a <style> tag with the nonce

The policy allows a <style> tag with the nonce, so the server can write the rule itself:

const nonce = (await headers()).get('x-nonce') ?? undefined

return (
  <>
    <style nonce={nonce}>{`.${className} { grid-template-columns: ${columns}; }`}</style>
    <PackageTable className={className} />
  </>
)

This is the fix most people suggest first, so I tested it as well. On a fresh load it works. On a client-side navigation it breaks, with the same message as the original bug. Why? Because a client-side navigation is also a request. Next.js fetches the server components of the new page, the proxy runs for that request too, and it generates a brand new nonce. The new <style> tag carries that new nonce, while the page is still governed by the policy that came with the first document. In the demo, the nonce of the document started with MjY1ODNi and the one of the new style tag with ZGFhZDMx.

You could make it work by reading the document's nonce in the browser and passing it down, but then you manage nonces by hand to avoid a problem that a stylesheet doesn't have at all.

Fix 4: a stylesheet from your own origin

A stylesheet from your own origin is allowed by style-src 'self'. So why not let the server generate one for every layout? A route handler can return the rule as CSS:

// GET /css/grid?c=<grid-template-columns value>
export function GET(request: NextRequest) {
  const columns = request.nextUrl.searchParams.get('c') ?? ''

  if (!isValidTrackList(columns)) {
    return new Response('/* invalid track list */\n', {
      status: 400,
      headers: { 'Content-Type': 'text/css; charset=utf-8' },
    })
  }

  const css = `.${gridClassName(columns)} { grid-template-columns: ${columns}; }\n`

  return new Response(css, {
    headers: {
      'Content-Type': 'text/css; charset=utf-8',
      'Cache-Control': 'public, max-age=31536000, immutable',
      'X-Content-Type-Options': 'nosniff',
    },
  })
}

And the component links to it:

<link
  rel="stylesheet"
  href={`/css/grid?c=${encodeURIComponent(columns)}`}
  precedence="grid"
/>
<PackageTable className={gridClassName(columns)} />

The precedence prop does most of the job here. In React 19 it turns the link into a stylesheet resource: React moves it into the <head>, renders every URL only once and waits for the stylesheet before it reveals the content that depends on it. I wanted to see it with my own eyes, so I delayed the stylesheet by 1.5 seconds in the test browser. On a client-side navigation the table didn't appear at all for about 1.45 seconds, and when it did, it already had four columns. It never showed one. On a fresh load a stylesheet in the <head> blocks rendering anyway.

There are two details that make this version cheap and safe.

Derive everything from the value. The class name is a short hash of the column string, calculated by the same function on the server and in the browser. The same layout always gives the same URL, so thanks to immutable the browser downloads it once and reuses it on every page. If you mixed anything per instance into the URL, for example a React useId, every table would become a new request.

Validate the input. This one is easy to miss. Whatever this route returns becomes CSS that your policy trusts. If it pasted its input as it is, anyone who found an HTML injection bug in your app could add a <link> to it and smuggle their own CSS through 'self'. They could hide the real content, draw a fake form on top of the page, or leak attribute values with CSS selectors. A grid-template-columns value needs only letters, digits, spaces, dots, commas, hyphens, percent signs and parentheses, and nothing that can close a rule or open a new one:

const TRACK_LIST = /^[a-z0-9.%(),\s-]{1,200}$/i

The route also builds the class name itself instead of taking it from the URL. Thanks to that, the only thing an attacker could inject is a column layout.

Libraries that inject styles at runtime

The same rule applies to third-party code:

  • Libraries that add a <style> tag at runtime get blocked unless they attach your nonce. Icon libraries are a classic example: the icons render at their raw SVG size, because their CSS was never applied. Most of them have a switch for it. In Font Awesome, set config.autoAddCss = false and import its stylesheet at build time instead. If you use CSS-in-JS, check if the library supports a nonce before you adopt it,
  • Positioning libraries, like Floating UI, set coordinates through a style prop. That's fine when the floating element is created in the browser after a click, because React applies the prop through the CSSOM. But a popover rendered by the server as already open would hit the attribute rule.

Checklist

Here is a short checklist of what works under a strict policy:

  • classes from your own stylesheets, including Tailwind: works,
  • a Tailwind arbitrary value: works, if the exact class name is in your source code,
  • importing a library's CSS file: works,
  • a style prop on anything rendered by the server: breaks, but only on a fresh load,
  • setting element.style.x from your own code: works,
  • calling setAttribute('style', ...): blocked,
  • a <style> tag with the nonce from a server component: breaks on a client-side navigation,
  • a value known only at runtime: a static class if you can, a stylesheet route with validation if you can't,
  • a library that injects <style> at runtime: turn the injection off and import its CSS.

Summary

A strict Content Security Policy is a great thing, but it protects you only in the environments where it is switched on. In my case the bug lived happily in production for a while, just because nobody ran the app with the policy enforced before the users did.

If you want to avoid such surprises, I suggest following the points below:

  • Keep styles in stylesheets, not in attributes,
  • Test a production build with the policy enforced,
  • Check both a fresh load and a client-side navigation,
  • Validate everything you echo into CSS.

I hope you find this article useful. If you have any questions or comments, do not hesitate to post them in the comments. Happy coding!