Rails Content Security Policy: CSP Headers, Nonces, and Turbo Compatibility
Rails content security policy: configure CSP headers, generate nonces for Turbo and Stimulus, fix violations, and deploy without breaking your app. Full guide.
The staging deploy went fine. Production failed immediately.
Not a crash — worse. The page loaded, but nothing worked. No dropdowns, no Turbo frame navigation, no Stimulus controllers responding. The browser console was a wall of red: Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Someone had enabled the CSP header in the load balancer configuration over the weekend as part of a security audit, and they had done it correctly — which meant everything we had shipped with inline scripts was now silently dead.
After nineteen years of Rails I have seen this exact scenario play out a dozen times. CSP is not difficult, but it punishes you immediately and completely when you configure it wrong, and it requires knowing what Turbo, Stimulus, Action Cable, and your asset pipeline actually inject into the DOM before you can write a policy that works.
This is that guide.
What Rails Content Security Policy Is and Why It Matters
Rails content security policy is an HTTP response header that tells browsers which sources of content — scripts, styles, images, fonts, WebSocket connections — they are allowed to load. Anything not on the list gets blocked before it executes.
From a security perspective, CSP is the last line of defence against cross-site scripting. If an attacker injects <script>alert(document.cookie)</script> into your page, a properly configured CSP blocks that script from executing even if the injection succeeds. It does not prevent the injection — that is what input validation and parameterized queries are for — but it limits the blast radius from a worst-case XSS vulnerability to noise rather than credential theft.
Rails has shipped a built-in CSP DSL since version 5.2. You do not need a gem.
Configuring Rails Content Security Policy
The configuration lives in config/initializers/content_security_policy.rb. A generator creates the file:
bin/rails generate content_security_policy
The default generated file is commented out. Here is a working baseline for a modern Rails 8 app with Turbo and Stimulus:
# config/initializers/content_security_policy.rb
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self, :https
policy.font_src :self, :https, :data
policy.img_src :self, :https, :data, :blob
policy.object_src :none
policy.script_src :self
policy.style_src :self, :https
policy.connect_src :self, :https
# Allow Action Cable WebSocket connections
# Replace with your actual domain in production
policy.connect_src :self, :https, "wss://yourapp.com"
# Report violations to your endpoint
policy.report_uri "/csp_violation_reports"
end
# Enable nonces for inline script and style tags (required for Turbo)
config.content_security_policy_nonce_generator = ->(_request) {
SecureRandom.base64(16)
}
config.content_security_policy_nonce_directives = %w[script-src style-src]
end
The nonce_generator is the critical piece. Turbo and Rails both inject inline <script> tags — for things like Turbo’s data islands and csrf_meta_tags. A nonce is a per-request cryptographic token that is added to the CSP header and to each inline script tag. The browser accepts inline scripts that carry the matching nonce and rejects everything else.
How Rails Content Security Policy Nonces Work with Turbo
Rails automatically adds the nonce attribute to inline scripts generated by its own helpers. The javascript_tag helper, csrf_meta_tags, and javascript_importmap_tags all receive the nonce automatically when nonces are enabled.
In your layout:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", nonce: content_security_policy_nonce %>
<%= javascript_importmap_tags %>
</head>
The csp_meta_tag helper exposes the current nonce as <meta name="csp-nonce">. Turbo reads this tag and uses the nonce value when it injects script elements into the page during navigation — which is exactly why enabling nonces without this meta tag causes Turbo Drive to silently break on the second page navigation.
Check that your Turbo script tag carries the nonce in development:
<!-- What you should see in page source -->
<script type="module" nonce="abc123==" src="/assets/application.js"></script>
No nonce attribute means the nonce is not being injected. Check that content_security_policy_nonce_generator is set and that you are using javascript_importmap_tags or javascript_include_tag rather than a raw <script> tag in your layout.
For Turbo Frames and Turbo Streams specifically, the nonce approach is the only option that works reliably. The alternative — 'unsafe-inline' in script-src — defeats the purpose of having a CSP at all. Do not do it.
Report-Only Mode: Audit Before You Enforce
Never enable CSP enforcement in production without a reporting phase first. The correct sequence:
- Deploy with
Content-Security-Policy-Report-Only, pointing violations at your reporting endpoint - Run in report-only mode for one to two weeks
- Fix every violation category that appears in the reports
- Switch to
Content-Security-Policyenforcement
Rails makes report-only mode a one-line change:
config.content_security_policy_report_only = true
In report-only mode the browser blocks nothing — it sends a JSON POST to your report_uri whenever it encounters content that would have been blocked. The page keeps functioning while you discover every gap.
The violation report endpoint:
# config/routes.rb
post "/csp_violation_reports", to: "csp_reports#create"
# app/controllers/csp_reports_controller.rb
class CspReportsController < ApplicationController
skip_before_action :verify_authenticity_token
skip_before_action :authenticate_user!, raise: false
def create
body = request.body.read
report = JSON.parse(body).fetch("csp-report", {})
Rails.logger.warn(
event: "csp_violation",
blocked_uri: report["blocked-uri"],
violated_directive: report["violated-directive"],
document_uri: report["document-uri"],
source_file: report["source-file"],
line_number: report["line-number"]
)
head :no_content
rescue JSON::ParserError
head :no_content
end
end
With structured logs flowing into your observability stack — the OpenTelemetry Rails 8 guide covers the setup — group violations by violated_directive and blocked_uri. One URI showing up a thousand times means one policy gap to close. Fix it, redeploy, watch it vanish from the reports.
Action Cable and WebSocket Sources
Rails content security policy requires explicit permission for WebSocket connections via the connect-src directive. Action Cable connects by WebSocket, and connect-src controls which origins those connections can target.
For Solid Cable without Redis, the connection goes to your own domain. The most reliable pattern is to set this dynamically based on environment:
Rails.application.configure do
config.content_security_policy do |policy|
# ... other directives ...
policy.connect_src do
if Rails.env.production?
[:self, :https, "wss://yourapp.com"]
else
[:self, "http://localhost:3000", "ws://localhost:3000"]
end
end
end
end
The block form of directive configuration evaluates at request time, letting you switch values by environment without duplicating the entire policy across initializers. Use it for anything that differs between development and production — WebSocket URLs, CDN origins, error reporting endpoints.
Handling Third-Party Sources: Fonts, Images, and Analytics
The moment you load anything from a CDN, Google Fonts, or a third-party analytics service, that origin needs to be explicitly listed in the appropriate directive.
A realistic production CSP for a Rails app that uses Cloudfront for assets, Google Fonts for typography, and Sentry for error monitoring:
config.content_security_policy do |policy|
policy.default_src :self, :https
policy.font_src :self, :https, "https://fonts.gstatic.com"
policy.img_src :self, :https, :data, :blob,
"https://yourapp.s3.amazonaws.com",
"https://*.cloudfront.net"
policy.object_src :none
policy.script_src :self, :https
policy.style_src :self, :https, "https://fonts.googleapis.com"
policy.connect_src :self, :https,
"wss://yourapp.com",
"https://o123456.ingest.sentry.io"
policy.frame_src :none
policy.report_uri "https://o123456.ingest.sentry.io/api/123456/security/?sentry_key=..."
end
Note policy.frame_src :none. Unless your app embeds iframes or is embedded by third parties, locking this down is free hardening. Similarly, policy.object_src :none disables Flash and other plugin content — there is no legitimate reason to permit it in a modern Rails app.
Sentry’s security endpoint doubles as a CSP report destination, which is convenient: violations flow into the same dashboard as your exceptions. Check Sentry’s documentation for the exact URL for your organization and project.
Testing Your Rails Content Security Policy
Testing CSP locally requires enabling it, which breaks things in development until the policy is right. The pattern I use:
# config/environments/development.rb
Rails.application.configure do
config.content_security_policy_report_only = true
end
# config/environments/production.rb
Rails.application.configure do
config.content_security_policy_report_only = false
end
With this in place, development runs in report-only mode. Open the browser console while using the app normally — Chrome and Firefox surface CSP violation messages with clear details about which directive was violated and which resource was blocked.
A lightweight request spec that asserts the policy is present and sane:
# spec/requests/csp_spec.rb
RSpec.describe "Content Security Policy", type: :request do
before { get root_path }
it "sets a CSP header" do
expect(response.headers).to have_key("Content-Security-Policy-Report-Only")
.or have_key("Content-Security-Policy")
end
it "disallows object-src" do
header = response.headers["Content-Security-Policy"] ||
response.headers["Content-Security-Policy-Report-Only"]
expect(header).to include("object-src 'none'")
end
it "includes a nonce in the script-src directive" do
header = response.headers["Content-Security-Policy"] ||
response.headers["Content-Security-Policy-Report-Only"]
expect(header).to match(/script-src.*'nonce-[A-Za-z0-9+\/]+=*'/)
end
end
These three assertions — header present, object-src locked, nonce included — catch the most common CSP misconfigurations before they reach production.
Deploying Without Breaking Production
The sequence that works reliably:
- Add
config/initializers/content_security_policy.rbwith your policy andreport_only = true - Add the violation report endpoint to routes and the controller
- Deploy to production. Let violation reports accumulate for one to two weeks.
- Fix each violation category in the reports. Common offenders: inline
onclickhandlers in legacy HTML, third-party scripts that calleval(), resources loaded from domains you had forgotten about. - When violation volume drops to zero or near-zero, set
report_only = falseand redeploy. - Keep
report_uriactive even after switching to enforcement. Violations in enforcement mode represent real blocked content — you want to know about them immediately.
The Rack Attack rate limiting guide is worth consulting if your violation endpoint gets hammered by automated scanners — it is a public POST endpoint that will get discovered.
FAQ
Does Rails content security policy work with Importmap and Propshaft?
Yes. javascript_importmap_tags automatically applies the nonce to the generated <script type="importmap"> and <script type="module-shim"> tags. Propshaft serves assets from /assets/ — script-src 'self' covers them without any CDN source. No extra configuration needed if you are self-hosting assets.
How do I allow inline styles required by Turbo and Stimulus?
Turbo occasionally sets inline styles for its progress bar and some animations. Add style-src-attr 'unsafe-inline' as a narrow override if you need it — styles cannot exfiltrate data the way scripts can, so unsafe-inline on styles is a pragmatic tradeoff. Alternatively, include style-src in content_security_policy_nonce_directives and use the nonce helper explicitly for any inline styles you control.
Why does my CSP break after upgrading Turbo?
Turbo major versions occasionally change how they inject scripts into the DOM. After upgrading Turbo, switch to report-only mode for a few days and check violation reports before re-enabling enforcement. Breaking CSP changes in Turbo are rare but documented in the CHANGELOG — search for “nonce” and “inline” in each release’s notes.
Can I use nonces with a CDN like Cloudflare?
Nonces require a unique value per request, which means responses with nonces cannot be served from the edge cache. If you put your HTML behind a CDN, either configure cache-bypass for HTML responses (and cache assets only) or accept that cached pages cannot use nonces. Most Rails apps serve HTML from origin and use the CDN for static assets only — in that setup, nonces work without any special configuration and the CDN caches everything it should.
Tightening your Rails app’s security posture and not sure where to start? TTB Software has hardened production Rails applications for clients in fintech, healthcare, and SaaS for nineteen years. We know which security headers actually matter and which create maintenance cost without meaningful protection.
Related Articles
Rails Event Sourcing: Append-Only Domain Events, Projections, and CQRS in Production
Rails event sourcing: build append-only domain event logs, write projections, and implement CQRS patterns in producti...
Rails Zeitwerk Autoloading: Fix NameErrors, Eager Loading, and the Classic Loader Migration
Rails Zeitwerk autoloading explained: fix NameErrors, understand eager loading gaps, migrate from Classic loader, and...
Rails API Versioning: URL Namespaces, Header Routing, and Graceful Deprecation
Rails API versioning done right: URL namespaces, Accept header routing, controller inheritance, and Sunset headers fo...