Skip to main content

Implementing a Solid Content Security Policy in Rails 8

13 min read By Tom C

A Content Security Policy tells the browser which sources it's allowed to load resources from. If an attacker manages to inject a script into your page — through an XSS vulnerability, a compromised third-party dependency, or user-supplied content — a properly configured CSP is what stops that script from executing or exfiltrating data.

Rails has had built-in CSP support since Rails 5.2. In Rails 8, with Hotwire, Importmap, and Action Cable as defaults, the configuration is slightly more involved than a basic application — but it's all handled cleanly through the DSL. This post walks through setting it up properly, from a restrictive baseline to handling the edge cases you'll actually encounter.

Why bother

Plenty of teams skip CSP because it feels like friction. Headers need tuning, third-party integrations need whitelisting, and a wrong configuration can silently break functionality. The reasoning is understandable, but the risk calculus is wrong.

XSS remains one of the most exploited vulnerability classes. A CSP doesn't prevent the injection — it prevents the execution. Combined with proper output escaping (which Rails handles by default), a well-configured CSP makes a large category of attacks non-viable even when vulnerabilities exist.

The friction is also mostly front-loaded. Once your policy is established and report-only mode has caught the edge cases, it's stable.

The Rails 8 CSP DSL

Rails generates a CSP initializer when you create a new application. If you're working with an existing app, add it:

rails generate content_security_policy

This creates config/initializers/content_security_policy.rb. Here's a solid starting point for a Rails 8 application:

# config/initializers/content_security_policy.rb

Rails.application.config.content_security_policy do |policy|
  # Fallback for any directive not explicitly set
  policy.default_src :self

  # Scripts: self + nonce for importmaps and inline Hotwire
  policy.script_src :self, :nonce

  # Styles: self + nonce for any inline style attributes Stimulus may write
  policy.style_src :self, :nonce

  # Images: self + data URIs (used by Active Storage previews and some gems)
  policy.img_src :self, :data, :https

  # Fonts: self only (add your CDN if serving fonts externally)
  policy.font_src :self

  # XHR, fetch, WebSocket — self + wss for Action Cable
  policy.connect_src :self, :https

  # Disallow plugins (Flash, Java applets etc.) entirely
  policy.object_src :none

  # Disallow embedding in iframes from other origins
  policy.frame_ancestors :none

  # Restrict form submissions to same origin
  policy.base_uri :self
  policy.form_action :self
end

# Use a per-request nonce for script-src and style-src
Rails.application.config.content_security_policy_nonce_generator =
  ->(request) { SecureRandom.base64(16) }

Rails.application.config.content_security_policy_nonce_directives =
  %w[script-src style-src]

A few decisions worth noting here:

  • :nonce is the Rails DSL keyword for 'nonce-{value}'. The nonce (number used once) is generated per-request and injected automatically into script tags rendered with Rails helpers.
  • frame_ancestors :none is the CSP equivalent of X-Frame-Options: DENY. Prefer CSP — it's more expressive and X-Frame-Options is being deprecated.
  • form_action :self prevents a form on your page from being submitted to an external URL — an often-overlooked attack vector.
  • object_src :none is unconditional. There is no good reason to allow browser plugins in a modern application.

Wiring up the nonce (number used once) with Importmap

Rails 8 uses Importmap by default. The import map script tag needs to carry the nonce, or it'll be blocked by script-src. Rails handles this automatically when you use the standard helpers in your layout:

<%# app/views/layouts/application.html.erb %>
<html>
  <head>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "application", nonce: content_security_policy_nonce %>
    <%= javascript_importmap_tags nonce: content_security_policy_nonce %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

The csp_meta_tag outputs <meta name="csp-nonce" content="...">, which Turbo reads to apply the nonce to dynamically inserted script tags. Without it, Turbo-driven page updates will strip scripts loaded via Turbo Streams.

Handling Action Cable and Turbo Streams

If your application uses Action Cable (required for Turbo Streams over WebSocket), connect_src needs to allow WebSocket connections. The origin differs between development and production:

Rails.application.config.content_security_policy do |policy|
  # ... other directives ...

  policy.connect_src :self, :https,
    ActionController::Base.helpers.asset_path("/cable") if Rails.env.production?

  # Development: allow the local WebSocket server
  if Rails.env.development?
    policy.connect_src :self, "http://localhost:3000",
                              "ws://localhost:3000"
  end
end

Or use a helper to build the WebSocket URL from your configured host:

Rails.application.config.content_security_policy do |policy|
  # ... other directives ...

  ws_scheme = Rails.env.production? ? "wss" : "ws"
  policy.connect_src :self, :https,
    "#{ws_scheme}://#{Rails.application.config.action_cable.url&.then { URI.parse(_1).host } || "localhost"}"
end

For most applications, the simplest and most readable approach is just to be explicit in each environment:

# config/initializers/content_security_policy.rb

Rails.application.config.content_security_policy do |policy|
  policy.default_src :self
  policy.script_src  :self, :nonce
  policy.style_src   :self, :nonce
  policy.img_src     :self, :data, :https
  policy.font_src    :self
  policy.object_src  :none
  policy.frame_ancestors :none
  policy.base_uri    :self
  policy.form_action :self

  if Rails.env.production?
    policy.connect_src :self, :https, "wss://yourapp.com"
  else
    policy.connect_src :self, "http://localhost:3000", "ws://localhost:3000"
  end
end

Start with report-only mode

Never ship a new CSP policy directly to Content-Security-Policy. Use Content-Security-Policy-Report-Only first — the browser enforces nothing, but sends violation reports for anything that would have been blocked.

# config/initializers/content_security_policy.rb

Rails.application.config.content_security_policy_report_only = true

Add a reporting endpoint to capture violations. A minimal controller:

# config/routes.rb
post "/csp-violation-report", to: "csp_reports#create"

# app/controllers/csp_reports_controller.rb
class CspReportsController < ActionController::Base
  skip_before_action :verify_authenticity_token

  def create
    report = JSON.parse(request.body.read)
    violation = report["csp-report"] || report

    Rails.logger.warn("[CSP Violation] #{violation["blocked-uri"]} " \
                      "blocked on #{violation["document-uri"]} " \
                      "(#{violation["violated-directive"]})")

    head :no_content
  end
end

Tell the policy where to send reports:

policy.report_uri "/csp-violation-report"

Run report-only mode for a week in staging, then in production. Genuine violations from your own assets mean your directives need adjusting. Violations from random external origins are noise (or someone probing your application). Once the report volume from legitimate sources drops to zero, switch to enforcement.

Per-controller and per-action overrides

Some controllers legitimately need different rules — an admin area that embeds an iframe, an API endpoint that needs to set different headers, a payments flow that loads a third-party widget.

class PaymentsController < ApplicationController
  content_security_policy do |policy|
    # Stripe.js requires loading from their CDN
    policy.script_src :self, :nonce, "https://js.stripe.com"
    policy.frame_src "https://js.stripe.com", "https://*.stripe.com"
    policy.connect_src :self, :https, "https://api.stripe.com"
  end
end

For a specific action only:

class EmbedController < ApplicationController
  content_security_policy only: :show do |policy|
    policy.frame_ancestors :self, "https://trusted-partner.com"
  end
end

To exempt an action from CSP entirely (use carefully — usually only appropriate for API endpoints returning JSON):

class Api::V1::WebhooksController < ActionController::API
  content_security_policy false
end

Common pitfalls

Inline event handlers. If your views contain onclick="..." or onload="..." attributes, they'll be blocked by script-src without unsafe-inline. Don't add unsafe-inline. Move the handlers to Stimulus controllers instead — which is better practice regardless of CSP.

Inline styles. Similarly, style="..." attributes in HTML are blocked by style-src without unsafe-inline. If you have legitimate inline styles (e.g. dynamically set widths from Ruby variables), wrap them in a Stimulus controller or extract them to CSS custom properties. For the few cases where truly dynamic inline styles are unavoidable, use the nonce:

<div style="<%= sanitize(@dynamic_style) %>" nonce="<%= content_security_policy_nonce %>">

Third-party scripts. Google Analytics, HubSpot, Intercom, and similar tools load scripts from their own CDNs and make additional network requests. Each one needs explicit allowlisting. The violation reports will tell you exactly what. Keep the list as short as possible — every external origin you allow is an attack surface.

Asset CDNs. If you serve assets from a CDN (CloudFront, Fastly), add the CDN hostname to script_src, style_src, font_src, and img_src as appropriate:

policy.script_src :self, :nonce, "https://assets.yourapp.com"
policy.style_src  :self, :nonce, "https://assets.yourapp.com"

Testing your policy

Rails' integration tests can assert on CSP headers:

class SecurityHeadersTest < ActionDispatch::IntegrationTest
  test "CSP header is present and restrictive" do
    get root_path

    csp = response.headers["Content-Security-Policy"]
    assert_not_nil csp
    assert_includes csp, "default-src 'self'"
    assert_includes csp, "object-src 'none'"
    assert_includes csp, "frame-ancestors 'none'"
    assert_not_includes csp, "'unsafe-inline'"
    assert_not_includes csp, "'unsafe-eval'"
  end
end

For a more thorough check, use securityheaders.com against a staging environment — it grades your full set of security headers and explains every flag.

The unsafe-inline trap

If you search for CSP solutions to broken inline scripts, you'll find a lot of advice suggesting unsafe-inline. Resist it. unsafe-inline in script-src defeats the primary purpose of having a CSP — it allows any inline script to execute, which is precisely what an XSS attack injects.

The number used once (nonce) approach is the correct solution. A nonce is generated fresh per request, added to your legitimate script tags by the Rails helpers, and is impossible for an attacker to guess. Inline scripts injected by an attacker carry no nonce and are blocked.

If you've inherited an application with unsafe-inline in its CSP, removing it is the right goal. Start with report-only mode, identify what actually relies on inline scripts, migrate those to Stimulus controllers or nonce-bearing tags, and then remove unsafe-inline from the enforced policy.

A CSP that permits unsafe-inline is not a content security policy — it's a header that tells you the application once tried to have one.


If you'd like a security review of your Rails application's headers, authentication, or input handling, we're happy to discuss it.