I’ve seen a lot of Rails apps with one of two CSP setups:
- no policy at all
- a policy that exists mostly to silence browser errors
Neither gives you much protection.
Rails actually makes CSP pretty workable, especially once you stop treating it like a static header and start treating it like application code. Here’s a real-world style case study based on the kind of policy you’ll see on production marketing and SaaS sites, including analytics, consent tooling, WebSockets, and a few legacy frontend habits that make CSP harder than it should be.
The app
Picture a Rails 7 app serving a product site plus authenticated dashboard pages. Stack looks familiar:
- Rails with ERB views
- Turbo and Stimulus
- Google Tag Manager
- Google Analytics
- Cookiebot consent banner
- Action Cable over WebSockets
- a couple of inline scripts in layouts
- some inline styles left behind by old partials
The team wanted a CSP after a pentest flagged weak XSS defenses. They already escaped output correctly most of the time, but “most of the time” is not how XSS works.
Before: no real protection
Their effective starting point was basically this:
# config/initializers/content_security_policy.rb
# commented out, never enabled
And in the layout:
<script>
window.appConfig = {
currentUserId: <%= current_user&.id.to_json %>,
environment: "<%= Rails.env %>"
};
</script>
<script>
dataLayer = dataLayer || [];
dataLayer.push({ pageType: "dashboard" });
</script>
There was also a helper generating HTML like this:
def promo_banner(message)
"<div class='promo'>#{message}</div>".html_safe
end
That helper is the kind of thing that turns a “small view bug” into script execution.
Without CSP, any reflected or stored XSS bug gets a clean runway. If an attacker lands JavaScript in the page, the browser runs it.
The first attempt: technically CSP, practically weak
The team’s first policy looked like a lot of first policies do:
Rails.application.config.content_security_policy do |policy|
policy.default_src :self, :https
policy.script_src :self, :https, :unsafe_inline, :unsafe_eval
policy.style_src :self, :https, :unsafe_inline
policy.img_src :self, :https, :data
policy.connect_src :self, :https, :wss
policy.object_src :none
end
This is better than nothing, but I’m pretty blunt about this: if you leave unsafe-inline in script-src, you’ve kept one of the main doors open.
Why teams do this is obvious. They need the app to keep working. GTM needs to load. Inline config scripts exist. Old snippets are everywhere. So they add broad allowances until the console goes quiet.
That’s normal. It’s also where a lot of CSP projects stall out.
A better target: policy shaped by what the app actually loads
A useful real-world reference is this production header from headertest.com:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-MDYwMGI5ZjctM2EwNi00ODQ0LTg1ZGEtM2VjZDlmNGQ0ZmI1' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://collect.tallytics.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
That’s not a toy policy. It reflects reality:
- third-party analytics and consent tooling
- nonce-based script trust
strict-dynamic- WebSocket endpoints
- locked down framing and object embedding
That’s a much healthier direction for a Rails app.
After: a Rails CSP that survives production
Here’s what the Rails version ended up looking like.
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self,
-> { "'nonce-#{content_security_policy_nonce}'" },
"'strict-dynamic'",
"https://www.googletagmanager.com",
"https://*.cookiebot.com",
"https://*.google-analytics.com"
policy.style_src :self,
:unsafe_inline,
"https://www.googletagmanager.com",
"https://*.cookiebot.com",
"https://consent.cookiebot.com"
policy.img_src :self, :data, :https
policy.font_src :self
policy.connect_src :self,
"https://api.example.com",
"https://or.example.com",
"wss://or.example.com",
"https://*.google-analytics.com",
"https://*.googletagmanager.com",
"https://*.cookiebot.com"
policy.frame_src :self, "https://consentcdn.cookiebot.com"
policy.frame_ancestors :none
policy.base_uri :self
policy.form_action :self
policy.object_src :none
end
Rails.application.config.content_security_policy_nonce_generator = ->(request) {
SecureRandom.base64(16)
}
Rails.application.config.content_security_policy_nonce_directives = %w(script-src)
A few opinions here:
default-src :selfis a good baseline. Be explicit after that.object-src 'none'should be standard unless you enjoy supporting dead browser plugin models from another era.frame-ancestors 'none'is great for dashboards and admin. For embeddable products, you may need something else.strict-dynamicis worth using when you’re already on nonces and have trusted bootstrap scripts.
Fixing the views: this is where CSP projects succeed or fail
The policy only worked after the view layer changed.
Before: unsafe inline scripts
<script>
window.appConfig = {
currentUserId: <%= current_user&.id.to_json %>,
environment: "<%= Rails.env %>"
};
</script>
After: nonce the script
<script nonce="<%= content_security_policy_nonce %>">
window.appConfig = {
currentUserId: <%= current_user&.id.to_json %>,
environment: <%= Rails.env.to_json %>
};
</script>
That one change is the difference between “browser runs any inline script” and “browser runs only inline script we explicitly blessed for this request.”
Same thing for GTM bootstrap code:
<script nonce="<%= content_security_policy_nonce %>">
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ pageType: "dashboard" });
</script>
If you can move inline code into compiled assets, even better. I usually recommend doing that for app-owned code and reserving nonces for the snippets that are awkward to externalize.
The annoying part: styles
In this case, style-src still had unsafe-inline. I don’t love that, but I’ve shipped policies like this because third-party consent tools and old Rails partials often inject inline styles.
If your app can avoid inline styles entirely, do it. If not, accept that style-src 'unsafe-inline' is often a transitional compromise, not a reason to abandon CSP altogether.
One subtle improvement: stop overusing default-src
A lot of teams stuff domains into default-src and call it done. That gets messy fast.
Compare these two approaches.
Weak and vague
policy.default_src :self,
"https://www.googletagmanager.com",
"https://*.cookiebot.com",
"https://*.google-analytics.com",
:https
Better and intentional
policy.default_src :self
policy.script_src :self, -> { "'nonce-#{content_security_policy_nonce}'" }, "'strict-dynamic'", "https://www.googletagmanager.com"
policy.connect_src :self, "https://*.google-analytics.com", "https://*.googletagmanager.com"
policy.frame_src :self, "https://consentcdn.cookiebot.com"
The second version tells you what each dependency is allowed to do. That matters during reviews and incident response.
Rollout strategy that didn’t break production
I rarely enable an enforcing CSP on day one for a nontrivial Rails app. The safer path is:
- start with
Content-Security-Policy-Report-Only - collect violations
- fix app-owned inline scripts
- add only the third-party origins you actually need
- switch to enforcement
Rails supports report-only policy too:
Rails.application.config.content_security_policy_report_only = true
For teams that want examples to compare against, https://csp-examples.com is handy for policy patterns, and the Rails docs cover the framework-specific CSP API well: https://edgeguides.rubyonrails.org/security.html#content-security-policy-header
What changed after rollout
After the final rollout, the team got a few concrete wins:
- inline script execution now required a valid nonce
- random injected
<script>tags stopped executing - third-party access was constrained to known destinations
- clickjacking risk dropped with
frame-ancestors 'none' - future frontend changes had to declare their dependencies instead of silently loading anything
The surprising benefit was cultural. Frontend and Rails developers became much more aware of where scripts came from and why. CSP forced inventory. Inventory is where security usually starts getting real.
Final before-and-after snapshot
Before
policy.default_src :self, :https
policy.script_src :self, :https, :unsafe_inline, :unsafe_eval
policy.style_src :self, :https, :unsafe_inline
policy.img_src :self, :https, :data
policy.connect_src :self, :https, :wss
After
policy.default_src :self
policy.script_src :self,
-> { "'nonce-#{content_security_policy_nonce}'" },
"'strict-dynamic'",
"https://www.googletagmanager.com",
"https://*.cookiebot.com",
"https://*.google-analytics.com"
policy.style_src :self,
:unsafe_inline,
"https://www.googletagmanager.com",
"https://*.cookiebot.com",
"https://consent.cookiebot.com"
policy.img_src :self, :data, :https
policy.font_src :self
policy.connect_src :self,
"https://api.example.com",
"https://or.example.com",
"wss://or.example.com",
"https://*.google-analytics.com",
"https://*.googletagmanager.com",
"https://*.cookiebot.com"
policy.frame_src :self, "https://consentcdn.cookiebot.com"
policy.frame_ancestors :none
policy.base_uri :self
policy.form_action :self
policy.object_src :none
That’s what a production-friendly Rails CSP looks like to me: not perfect, not theoretical, but strong enough to materially reduce XSS risk without declaring war on your own app.
If your Rails app still has no CSP, start with report-only, add nonces, kill unsafe-inline for scripts, and make every allowed domain earn its place. That’s the part that pays off.