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:
:nonceis 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 :noneis the CSP equivalent ofX-Frame-Options: DENY. Prefer CSP — it's more expressive andX-Frame-Optionsis being deprecated.form_action :selfprevents a form on your page from being submitted to an external URL — an often-overlooked attack vector.object_src :noneis 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.