The bug report
Our admin dashboard shipped to production. Within an hour, a sharp-eyed reviewer pinged: the sidebar nav links weren't clickable, two icons had escaped their containers, and the tier labels from the dashboard chart were floating in the gutter to the left of the sidebar.
It worked fine locally.
The browser console had this, repeated for every single inline style on the page:
Refused to apply inline style because it violates the following Content Security Policy directive:
"style-src 'self' https: 'unsafe-inline' 'nonce-0pbklGMD8AUlwsnFfcfaog=='".
Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list.
Read that error twice. The browser is telling us, politely, that we did allow 'unsafe-inline' in the policy, and it did see the directive, and it deliberately ignored it because there was also a nonce in the same directive.
That sentence is the entire problem.
How we got here
We had a strict app-wide Content Security Policy. Defence in depth, no inline scripts or styles by default, every directive nonce-based. The relevant config in our Rails initializer:
# The app-wide CSP initializer
Rails.application.configure do
config.content_security_policy do |policy|
policy.script_src :self, :https
policy.style_src :self, :https
# ... other directives ...
end
config.content_security_policy_nonce_generator =
->(_request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
end
Those last two lines are the important ones, and they do different jobs. The generator mints a fresh nonce on every request. nonce_directives decides which directives that nonce gets appended to, here both script-src and style-src. The same value is then available to csp_meta_tag, which emits it as a <meta name="csp-nonce"> tag for client-side libraries to read, and to the asset helpers, which stamp it onto tags you opt into with nonce: true. Rails does not silently nonce every inline element you render server-side; you have to ask for it.
This is the recommended modern setup. Nonces beat 'unsafe-inline' because they let a specific, known piece of inline content through while still blocking arbitrary inline content injected by an attacker. Good defaults.
Then we built the admin dashboard. The dashboard uses a charting library which, like a lot of charting libraries, generates its own SVG containers and applies positioning styles inline at runtime. It does this for tooltip placement, animation transforms, and axis label rotation. The library doesn't know about our nonce. Its inline styles were getting blocked, charts rendered broken, and one of the broken chart fragments overlaid the sidebar and stole the click events.
The pragmatic fix, scoped to the admin layout where we accepted the trade-off, looked like this:
# In the admin section's base controller -- inherited by every admin page
content_security_policy do |policy|
existing_style_src = Array(policy.style_src)
policy.style_src(*(existing_style_src + [:unsafe_inline]).uniq)
end
Read that as: "in addition to whatever sources the global policy already permits for style-src, also allow 'unsafe-inline'." On admin pages only. Defence in depth elsewhere.
It looked right. It didn't work.
Before we get to why, one aside about that snippet, because it reads like a getter and isn't. Action Pack defines every directive as a single method that both reads and writes:
# action_dispatch/http/content_security_policy.rb
define_method(name) do |*sources|
if sources.first
@directives[directive] = apply_mappings(sources)
else
@directives.delete(directive) # a no-arg call DELETES the directive
end
end
Calling policy.style_src with no arguments removes style-src from the policy and returns whatever was there. The append above works only because the very next line puts it back. If anything ever short-circuits between those two lines, style-src vanishes entirely and inline styles quietly fall through to default-src. That isn't what broke us, but it's the kind of thing you inherit by copying, so leave a comment wherever you use the pattern.
The CSP3 spec, hidden in plain sight
When CSP Level 3 was finalised, the spec writers had to settle a question: what should a browser do if a style-src directive contains both a nonce (or hash) and 'unsafe-inline'? The two are contradictory. The nonce says "trust only this specific inline content." 'unsafe-inline' says "trust all inline content." Honouring both would mean honouring 'unsafe-inline', which makes the nonce pointless.
The spec's answer: if a nonce or hash is present, ignore 'unsafe-inline'.
This rule exists for legacy migration. The intent is to let sites add nonces without breaking. Older browsers that don't understand nonces still see 'unsafe-inline' and apply the styles; newer browsers see the nonce, ignore the 'unsafe-inline', and enforce the strict policy. A graceful fallback path.
It's a great rule for migration. It's a footgun when you don't realise it applies.
In our case:
- The global initializer added a nonce to
style-srcon every request. - The admin controller appended
'unsafe-inline'to the same directive. - The browser saw both, decided the nonce won, and silently dropped
'unsafe-inline'from the effective policy. - The charting library's inline styles, which carried no nonce, got blocked exactly as if we hadn't added
'unsafe-inline'at all.
The browser even told us. That last sentence of the CSP error message ("Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list") is Chrome restating the spec rule for us, in the console, on every violation. We just hadn't connected it to our config.
The dev-environment trap
Locally, none of this surfaced. Our policy in dev was:
config.content_security_policy_report_only = Rails.env.development?
Report-only mode tells the browser to log violations but not block content. The CSP error appeared in the console with the same wording and the same "ignored if a nonce is present" hint as production, differing only by a [Report Only] prefix Chrome adds when the policy isn't enforced. The styles still applied and the page rendered correctly. Charts worked. Sidebar links worked.
This is the second-order lesson. Report-only mode hides the symptom of CSP misconfigurations, even though it surfaces the violation. When you read a CSP error in dev, your eye learns to skip past it because the page works anyway. By the time the policy is enforced in prod, you've stopped reading the same error you've been seeing for weeks.
We'd been seeing the warning the entire time we built the admin dashboard. It just didn't matter until it did.
The fix
We needed 'unsafe-inline' to actually take effect on admin pages. That meant removing the nonce from style-src for those requests only. Rails has had the API for it on the request object since 6.1:
# Same admin base controller as above
before_action :disable_style_src_nonce
private
def disable_style_src_nonce
# Strip style-src from this request's nonce-directive list. The
# appended :unsafe_inline above is now honoured by browsers because
# there's no nonce left in style-src for the spec's fallback rule to
# act on. script-src remains nonce-based -- admin doesn't need to
# relax script policy, just style policy.
request.content_security_policy_nonce_directives = %w[script-src]
end
Per-request override. One line of behaviour change. The global policy, and every other controller in the app, are unaffected. Script-src on admin pages stays nonce-based. The chart library doesn't inject scripts, only styles, so the script policy can stay strict.
The doc block above this method now spells out the failure mode explicitly so the next person reading it doesn't have to rediscover the spec rule:
The per-request style-src nonce is dropped on admin responses. Per CSP3,
'unsafe-inline'is IGNORED whenever a nonce or hash appears in the same directive, and the global initializer adds a nonce to style-src on every request. Without dropping it here, the appended:unsafe_inlineabove would have no effect.
The deeper fix: making the chart library nonce-aware
:unsafe_inline on style-src, even scoped to authenticated pages, is a weaker posture than a pure nonce-based policy. Relaxing inline styles trades away some of CSP's defence-in-depth value, so it's worth understanding the stricter alternative even when the pragmatic relaxation is a reasonable call for a given surface.
The stricter fix is to make the chart library nonce-aware: extract the per-request nonce from <meta name="csp-nonce"> (csp_meta_tag already emits it in the layout) and stamp it onto every <style> element the library injects. Either patch the library or use a MutationObserver on the chart container to add the attribute as elements appear. That keeps the directive nonce-only and avoids :unsafe_inline entirely.
Whichever way you go, document the trade-off in the controller comment and on your internal backlog so the rationale isn't lost and the stricter option stays on the radar.
Lessons
1. CSP errors in report-only mode aren't background noise.
If the browser is logging a violation, the policy is wrong. The only thing report-only changes is whether the policy is enforced. Same warning, same root cause, same eventual production bug. Read every CSP warning as if it were a hard error.
A practical version of this: when you turn report-only off in production for the first time, scan the staging console for any pre-existing CSP warnings and fix them before the policy goes live. Don't ship the toggle and the cleanup at the same time.
2. When a modern CSP keyword sits next to a legacy fallback, the fallback loses.
The "nonce silently disables 'unsafe-inline'" rule is one example. 'strict-dynamic' is another: put it in script-src and the host allowlist, 'self', and 'unsafe-inline' are all ignored in favour of trust propagated from a nonce or hash. The generalisation isn't quite "the strictest specifier wins", because 'strict-dynamic' actually loosens what dynamically inserted scripts may do. It's that the newer, more expressive keyword wins and the older fallback sitting beside it is discarded.
Don't over-extend the pattern, though. 'unsafe-eval' is not an example. 'strict-dynamic' has no effect on it: if you allow eval, it stays allowed.
This is the right default for security, but it makes "let me also allow X" reasoning unreliable. If you're appending to a CSP directive that already has nonces, hashes, or 'strict-dynamic', your appended value may not do what you expect. Test each policy change with a real browser, not just curl -I to inspect the response header.
3. Per-request CSP overrides are a feature, use them.
Rails has supported controller-level content_security_policy blocks since 5.2, and per-request content_security_policy_nonce_directives since 6.1. Most teams I've seen use the controller-level block but never touch the nonce directives, usually because they don't realise it's per-request configurable. The result is global policies that have to be a compromise between every page's needs.
If you have a strict app and a few pages that need to relax in narrow ways, override at the controller. The blast radius stays scoped, the global policy stays strict, and the next developer reading the controller can see exactly what's relaxed and why.
4. Document the spec rule next to the workaround.
The single biggest reason this bug took two attempts to fix was that the first fix looked correct. Append 'unsafe-inline' to style-src: that is how CSP works in your head. The spec's silent override is invisible from the code. The only signal is the browser console message you've been ignoring.
When you write a controller-level CSP override, write a comment that explains why the workaround is shaped the way it is. Quote the spec rule. Future you, reading this in six months, will not remember.
Reading list
- W3C CSP Level 3, section 6.7.3.2, "Does a source list allow all inline behavior for type?": the actual spec language, including the step that stops honouring
'unsafe-inline'once a nonce or hash is in the list - MDN: the
Content-Security-Policyheader: says it in one line, under both the nonce and the hash source expressions. Note that the per-directive pages,style-srcincluded, do not mention this behaviour at all - Rails Guides: Securing Rails Applications, Content Security Policy: the Ruby-side primitives. It does not document the per-request nonce-directive override used above; for that, read
action_dispatch/http/content_security_policy.rbin Action Pack
