Hardening Rails Security: Implementing Strict Content Security Policy (CSP) and SRI Headers
Browser XSS filters were always a poor backstop, and they’re not even that anymore. The real defense is telling the browser exactly what it’s allowed to execute, and Content Security Policy is how you do that. Rails has shipped first-class CSP support since 5.2, and by Rails 7 the nonce story is complete enough to run strict. The mistake most teams make isn’t skipping CSP entirely — it’s a policy so loose that 'unsafe-inline' and a wildcard host make the header decorative.
This post is the strict version: static resources locked to :self, all inline scripts permitted via a fresh nonce per request, third-party scripts pinned with Subresource Integrity, and a rollout path that doesn’t break production on day one.
A strict policy with nonces
The policy lives in an initializer. Two things matter here: keep script_src to :self plus known hosts, and never combine 'unsafe-inline' with nonces — the presence of unsafe-inline silently disables nonce protection in older browsers, so a policy with both is effectively just the unsafe-inline. Either trust inline scripts or don’t; if you’re using nonces, drop unsafe-inline entirely.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
policy.font_src :self, :https, :data
policy.img_src :self, :https, :data
policy.object_src :none
policy.base_uri :self
policy.frame_ancestors :none
policy.style_src :self, :https
policy.script_src :self, :https, -> { request.content_security_policy_nonce }
end
Rails.application.config.content_security_policy_nonce_generator = :random
Rails.application.config.content_security_policy_nonce_directives = %w[script-src]
The block receives the request, so request.content_security_policy_nonce generates a unique nonce per page load. Note the subtle bug in the “set script_src twice” examples floating around: each directive assignment replaces the previous value for that directive, so you must combine :self, :https, and the nonce in a single line.
Then opt inline scripts into the nonce in your views — nothing runs without it:
1
2
3
4
<%= javascript_include_tag "application", nonce: true %>
<%= javascript_tag nonce: true do %>
window.analytics.configure({ id: "<%= ENV['ANALYTICS_ID'] %>" });
<% end %>
Anything not carrying a nonce — a data- attribute inserted by an attacker, a stray inline onclick — is refused by the browser. That’s the mechanism that turns a stored-XSS payload into a console error instead of an account takeover.
Pinning the third-party network
Nonces protect your inline scripts, but they do nothing for scripts you load from a CDN you don’t own. If js.stripe.com or the analytics bundle is compromised or served tampered bytes by a man-in-the-middle, your page loads whatever it’s given. Subresource Integrity pins the exact bytes:
1
2
3
<%= javascript_include_tag "https://js.stripe.com/v3/",
integrity: "sha384-<%= ENV['STRIPE_SRI_HASH'] %>",
crossorigin: "anonymous" %>
Generate the hash with openssl dgst -sha384 -binary < file | base64, and treat it as a reviewed, rotating secret: it must be updated whenever the vendor bumps their bundle, and a mismatch is a hard failure (browser refuses to run the script), which is exactly what you want from a tamper signal. Self-hosted, digested assets from Sprockets or Webpacker don’t need SRI — the digest already pins them — so apply it where the trust boundary actually is: third-party JavaScript.
Rolling out without a fire drill
Enforce last. For a week or two, run the policy in report-only mode so the browser tells you what it would have blocked:
1
Rails.application.config.content_security_policy_report_only = true
The violations landing in report_uri are your inventory of scripts that depend on inline execution. Our marketing site tripped this immediately: the A/B testing vendor injects inline scripts at runtime, and the reports showed 14k violations a day. Enforcing on day one would have silently killed the experiment’s tracking; report-only let us keep the policy armed while the vendor shipped a nonce-compatible build. Never treat report-only as optional — treat enforcement as the second step of a two-step deploy.
One caveat that bites everyone with a CDN in front: a page carrying a nonce must not be cached. If a CDN replays the cached HTML, it replays the nonce, and the “fresh per request” guarantee collapses to a shared secret. Set Cache-Control: private, no-store (or vary on the right cookie) on any HTML that carries script tags. Rails deduplicates the nonce within a single request, so multiple nonce: true tags on one page share the value safely — across requests is where it must not.
Beyond CSP
CSP’s frame-ancestors replaces X-Frame-Options. A couple of headers should ride along: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy to lock down camera/mic, and HSTS via config.force_ssl = true. The secure_headers gem packages all of it, but if you’re already in Rails 7 the built-in CSP config plus a small rack middleware for the rest is less dependency and easier to audit.
Production lessons
- Strict means strict: no
'unsafe-inline'next to nonces, nohttps:-wildcardscript-srcif:selfcovers your real asset hosts. - Nonces beat SHA hashes for inline scripts (any edit invalidates a hash); combine nonce + strict hosts.
- Use report-only as the rollout phase, wired to alerting, not just logs.
- Kill CDN caching on HTML that carries nonces, or the nonce is a shared secret.
- Auditing third-party vendors is a recurring chore: every vendor script version bump needs a new SRI hash and a re-check of its CSP footprint. Put it in the dependency-update checklist or it will silently rot.
CSP is the difference between XSS being an engineering problem and XSS being a pager alert. In Rails 7 the tooling is mature enough that a strict policy with nonces is a long afternoon’s work, not a migration project.