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:
- A stylesheet, for example
<link rel="stylesheet" href="/app.css">, or the CSS generated by your bundler. The policy allows it thanks tostyle-src 'self', - A
<style>tag inside the page. Allowed only if it carries the nonce, - A
styleattribute on a single element. React writes one whenever it renders astyleprop 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:
- The server renders the table and sends
<div class="grid" style="grid-template-columns: ...">, - 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,
- 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
styleattribute 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, setconfig.autoAddCss = falseand 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
styleprop. 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
styleprop on anything rendered by the server: breaks, but only on a fresh load, - setting
element.style.xfrom 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!