I’ve seen a lot of Rails apps with one of two CSP setups:

  1. no policy at all
  2. 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 :self is 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-dynamic is 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:

  1. start with Content-Security-Policy-Report-Only
  2. collect violations
  3. fix app-owned inline scripts
  4. add only the third-party origins you actually need
  5. 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.