Rails Content Security Policy: A Practical Guide
Learn how to configure Rails Content Security Policy, use nonces, test in report-only mode, and fix CSP violations step by step.
By Jean Emmanuel Cadet • 19 min read
Cross-site scripting remains one of the most common web vulnerabilities, and Rails already does a lot to protect you from it. Templates escape output by default, and the framework ships with sensible security headers. But a single unescaped helper call or a compromised third-party script can still open the door. Content Security Policy (CSP) is a browser-enforced layer that limits what a page is allowed to load and execute, even when something goes wrong in your code.
This guide covers Ruby on Rails Content Security Policy from the ground up. You will learn how Rails CSP configuration works, how to use nonces, how to make CSP work with import maps, Turbo, and Stimulus, how to roll out a policy safely in report-only mode, and how to strengthen it over time. If you want a broader view of application hardening first, read the Ruby on Rails security best practices guide.
What Content Security Policy Is and Why It Matters
Content Security Policy is a security standard that lets your server tell the browser which sources of content are trusted for a given page. If a script, stylesheet, image, or frame comes from somewhere your policy does not allow, the browser refuses to load or run it.
Think of it as an allowlist for your page. Without CSP, a browser will execute any script it finds in the HTML. With CSP, the browser executes only scripts that match the rules you declared.
Common Attacks CSP Helps Mitigate
The main benefit of CSP is reducing the impact of Cross-Site Scripting (XSS). If an attacker manages to inject a <script> tag into your page, a strict policy can stop the browser from running it. This makes CSP a valuable part of any Rails XSS protection strategy.
CSP can also help with:
- Data exfiltration:
connect-srcandform-actionlimit where scripts and forms can send data. - Clickjacking:
frame-ancestorscontrols which sites can embed your pages in a frame. - Malicious third-party content: limiting script and frame sources reduces the damage from a compromised external service.
- Mixed content and plugin abuse:
object-src 'none'disables legacy plugins entirely.
What CSP Does Not Replace
CSP is an additional layer of defense, not a substitute for secure coding. It does not replace authentication, authorization, CSRF protection, output encoding, input validation, or secure session management. You still need to filter incoming parameters with strong parameters, keep Rails' output escaping intact, and protect sessions properly. A well-configured CSP limits the damage when one of those layers fails, but it should never be the only thing standing between an attacker and your users.
How CSP Works Through HTTP Response Headers
CSP is delivered as an HTTP response header named Content-Security-Policy. The browser reads it when it receives the response and applies the rules to that page. A simple example looks like this:
Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'
Each rule is called a directive. A directive names a type of content (such as img-src) followed by the allowed sources, and directives are separated by semicolons. Here, everything defaults to the same origin, images may also come from data: URLs, and plugins are blocked.
Because CSP is a header, it is added to the response before it leaves your server. In Rails, that happens inside the Rack middleware stack, after your controller has rendered the view. If you want to see where that step sits in the bigger picture, how the Rails request lifecycle works explains how requests and responses travel through middleware.
There is also a second header, Content-Security-Policy-Report-Only. It uses the same syntax, but the browser only reports violations instead of blocking anything. This is the safest way to introduce a new policy, and we will use it later in this guide.
How Rails Supports Content Security Policy
Rails has a built-in DSL for CSP, so you do not need a gem for most applications. The framework provides:
- A configuration file at
config/initializers/content_security_policy.rb - A Ruby DSL for writing directives with symbols and strings
- Automatic nonce generation for inline scripts and styles
- View helpers such as
csp_meta_tagandcontent_security_policy_nonce - Per-controller overrides
- A report-only mode and a reporting endpoint option
The Rails CSP Configuration File
New Rails applications include config/initializers/content_security_policy.rb, with the defaults commented out. A typical version looks like this once enabled:
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
policy.object_src :none
policy.script_src :self, :https
policy.style_src :self, :https
end
end
The config.content_security_policy block yields a policy object. Each method on it maps to a directive, so policy.script_src becomes script-src. Underscores turn into hyphens, and symbols like :self and :none become the quoted keywords 'self' and 'none'.
This starter policy is a reasonable learning tool, but note that :https in script_src allows scripts from any HTTPS host on the internet. That is much weaker than it looks, so we will tighten it in the practical example below.
Common CSP Directives Explained
A Rails CSP policy is built from directives. These are the ones you will use most often:
default-src: the fallback for any fetch directive you do not set explicitly. Starting with:selfis a strong default.script-src: controls JavaScript. This is the most important directive for XSS protection.style-src: controls stylesheets and inline<style>elements.img-src: controls images, includingdata:URIs and favicons.font-src: controls web fonts loaded through@font-face.connect-src: controlsfetch,XMLHttpRequest, WebSockets, and EventSource connections. This is how you allow your API or Action Cable.frame-src: controls which sources can be embedded in your page through<iframe>, for example a YouTube embed.object-src: controls<object>,<embed>, and<applet>. Setting it to:noneis almost always correct.media-src: controls<audio>and<video>sources.worker-src: controls Web Workers, Service Workers, and Shared Workers. It matters if you use a PWA service worker.frame-ancestors: controls who can embed your pages. It is the modern replacement for theX-Frame-Optionsheader.
Two more directives are worth adding to most apps: base-uri (prevents injected <base> tags from rewriting relative URLs) and form-action (limits where forms can submit).
One important detail: default-src does not cover frame-ancestors, base-uri, or form-action. Those must be set explicitly.
Understanding CSP Source Values
Each directive accepts a list of sources. These are the ones you will meet in Rails:
:selfallows the same origin (scheme, host, and port) as your page.:noneallows nothing.:httpsallows any HTTPS URL. It is convenient but broad.- A specific URL, such as
"https://plausible.io", allows exactly that host. This is usually the right choice for third-party services. :dataallowsdata:URIs, which are common for small inline images and fonts.:bloballowsblob:URLs, sometimes needed for workers or generated media.- Nonces are random per-request values that mark a specific inline script or style as trusted.
- Hashes (for example
"'sha256-...'") allow one specific inline block whose content matches the hash. :unsafe_inlineand:unsafe_evalrelax protections and are covered below.
Prefer the narrowest source that works. A policy with exact hosts, nonces, and no unsafe-* keywords gives you real protection.
Configuring a Starting Policy in Rails
A good first policy is restrictive. You begin by allowing only your own origin and adding exceptions as the app actually requires them.
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self
policy.style_src :self
policy.img_src :self, :data
policy.font_src :self
policy.connect_src :self
policy.object_src :none
policy.base_uri :self
policy.form_action :self
policy.frame_ancestors :self
end
config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
config.content_security_policy_report_only = true
end
This configuration does the following:
- Allows scripts, styles, images, fonts, and network requests only from your own origin, plus
data:images. - Blocks plugins completely through
object-src 'none'. - Prevents
<base>tag injection and restricts form submissions to your site. - Stops other sites from framing your pages.
- Generates a fresh random nonce on every request and applies it to
script-srcandstyle-src. - Runs in report-only mode, so nothing is blocked yet.
From a security standpoint, this is a strong baseline. The trade-off is that it will likely break pages that use external fonts, analytics, or inline scripts. That is expected, and report-only mode lets you find those cases safely. Customize each directive as your application proves it needs more.
If you generated your initializer with a session-based nonce such as request.session.id.to_s, consider switching to a random value per request. A nonce that stays the same across requests is much easier for an attacker to predict or reuse.
Using Nonces for Inline Scripts
Sometimes an inline script is unavoidable, such as a small bootstrap snippet or a third-party analytics loader. Instead of allowing every inline script with unsafe-inline, a nonce allows only the scripts you marked.
First, make sure your layout includes the CSP meta tag in the <head>:
<head>
<title>My App</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application" %>
<%= javascript_importmap_tags %>
</head>
csp_meta_tag renders a <meta name="csp-nonce"> tag containing the current nonce. Turbo and other libraries read it so they can attach the nonce to elements they inject.
Next, use the Rails helper for inline scripts:
<%= javascript_tag nonce: true do %>
window.appConfig = { locale: "<%= I18n.locale %>" };
<% end %>
With nonce: true, Rails adds the current request's nonce to the generated <script> tag, and the browser runs it because the nonce matches the header. An injected script would not have the nonce, so the browser blocks it.
For inline <style> elements, pass the nonce explicitly:
<%= content_tag :style, nonce: content_security_policy_nonce do %>
.banner { background: #fff8e1; }
<% end %>
Two cautions apply here. First, never interpolate untrusted data into an inline script, because a nonce will not save you from code you wrote to run attacker-controlled content. Second, avoid caching whole pages or fragments that contain nonces, since a cached nonce will not match the new response header.
CSP with Import Maps, Turbo, and Stimulus
Modern Rails uses import maps, Turbo, and Stimulus by default, and all three work well with a strict policy.
Import Maps
javascript_importmap_tags outputs inline <script type="importmap"> and module-loading tags. Rails automatically applies the CSP nonce to these tags when nonces are enabled for script-src, as long as you use the helper rather than hand-written tags.
By default, pinned packages from bin/importmap pin may point to a CDN such as ga.jspm.io. That would require allowing the CDN in script-src. A tighter option is to download the packages so they are served from your own origin:
bin/importmap pin lodash --download
The --download flag saves the file under vendor/javascript, so you can keep script-src at :self. This also removes a runtime dependency on a third-party host.
Turbo
Turbo Drive injects a small progress bar <style> element during navigation. It reads the nonce from the csp-nonce meta tag, which is why csp_meta_tag belongs in your layout. Without it, you may see a style violation whenever the progress bar appears. Turbo Streams and Turbo Frames work with the same origin, so no extra directives are usually needed.
Stimulus
Stimulus controllers are plain JavaScript files loaded as modules from your own origin. They do not need inline scripts or eval, so they work with script-src 'self' and a nonce. Keep behavior in controllers and use data-controller, data-action, and data-*-target attributes in your HTML, rather than inline onclick handlers, which a strict CSP will block.
CSP Considerations for Third-Party Services
Every external service you add is another host your policy must trust. Add them one at a time, and only to the directives they actually need.
External JavaScript Libraries
If you load a library from a CDN, add that exact host to script-src:
policy.script_src :self, "https://cdn.example.com"
This works, but it trusts everything the host serves. Where possible, vendor the library locally or use Subresource Integrity (SRI) with the integrity attribute so the browser verifies the file contents.
Fonts, Images, and Media
Google Fonts needs two entries, because the stylesheet and the font files live on different hosts:
policy.style_src :self, "https://fonts.googleapis.com"
policy.font_src :self, "https://fonts.gstatic.com"
For user-uploaded images served from Active Storage on a cloud bucket or an image CDN, add that host to img_src:
policy.img_src :self, :data, "https://res.cloudinary.com"
The implication is that anyone who can upload to that host could serve images to your pages, so keep the host as specific as possible.
APIs and WebSockets
Browser requests to another origin require connect-src:
policy.connect_src :self, "https://api.example.com"
If you use Action Cable, you may also need an explicit WebSocket entry such as "wss://example.com", since browsers differ in how 'self' applies to WebSocket URLs. Direct uploads to a storage bucket also need that bucket's host in connect-src. Keep API keys and tokens on the server, stored safely as described in how to use Rails credentials securely, rather than exposing them to client-side code.
Analytics
Analytics tools usually need three things: a script host in script-src, a collection endpoint in connect-src, and sometimes a tracking pixel host in img-src. For example, Plausible needs:
policy.script_src :self, "https://plausible.io"
policy.connect_src :self, "https://plausible.io"
Google Analytics and Google Tag Manager require several Google hosts, and those lists change over time, so check the vendor's current documentation. Tag managers deserve extra caution: they can load arbitrary scripts, which weakens your policy considerably.
Inline Styles, unsafe-inline, and unsafe-eval
CSP and Inline Styles
Inline <style> elements can be allowed with a nonce, as shown earlier. Inline style="..." attributes are different: a nonce does not apply to attributes, so a strict style-src blocks them. You have three options:
- Move the styles into your stylesheet and use CSS classes (the best option).
- Use CSS custom properties set from classes or data attributes.
- Allow attribute styles only, using
style-src-attr:
policy.style_src_attr :unsafe_inline
This allows inline style attributes without allowing inline style or script elements. Style injection is far less dangerous than script injection, so this is a common compromise, but it is still a relaxation you should make on purpose.
CSP and unsafe-inline
unsafe-inline tells the browser to run any inline script or style. In script-src, that defeats most of the XSS protection, because an injected <script> tag runs just like your own. If a nonce or hash is present in the same directive, modern browsers ignore unsafe-inline, which is why using nonces is the right path.
CSP and unsafe-eval
unsafe-eval allows eval(), new Function(), and similar string-to-code features. A default Rails stack with import maps, Turbo, and Stimulus does not need it. Some older libraries, certain template engines, and a few charting or UI packages do. If something requires it, look for a CSP-compatible build or a replacement before weakening your policy. Rails development tools may also need it in development only, so keep that out of production.
Why Allowing Everything Defeats the Purpose
A policy such as default-src * 'unsafe-inline' 'unsafe-eval' technically sends a CSP header, but it blocks nothing. The same goes for broad wildcards like https: in script-src. A policy is only as strong as its weakest directive, so avoid loosening it just because that makes the errors disappear.
Report-Only Mode and Safe Rollout
Report-only mode is the key to deploying CSP without breaking your site. The browser evaluates the policy, logs violations, and still lets everything load.
Enable it in the initializer:
config.content_security_policy_report_only = ENV.fetch("CSP_REPORT_ONLY", "true") == "true"Reading it from an environment variable lets you switch to enforcement on production without a code change once you are confident.
Collecting Violation Reports
Add a reporting endpoint to the policy:
policy.report_uri "/csp-violation-report"
Then add a route and controller:
post "/csp-violation-report", to: "csp_reports#create"
class CspReportsController < ApplicationController
skip_forgery_protection
def create
Rails.logger.warn("CSP violation: #{request.raw_post.truncate(2000)}")
head :no_content
end
end
Browsers send reports without your CSRF token, so the controller skips forgery protection. Because it is an unauthenticated endpoint that anyone can post to, keep it minimal: it only logs a truncated payload and does no database writes. Consider adding rate limiting or using a hosted reporting service. Treat report content as untrusted input and never render it back as HTML.
A Practical End-to-End Example
Here is how a realistic app might evolve its policy. Suppose the app uses import maps, Turbo, Stimulus, Google Fonts, Cloudinary images, a first-party JSON API on a separate subdomain, and Plausible analytics.
Step 1: Start Strict and Observe
Begin with the baseline policy shown earlier, in report-only mode. Browse the whole app, submit forms, and trigger Turbo navigation. Read the console warnings and logged reports. Each one tells you which directive needs attention.
Step 2: Add What the App Actually Needs
After reviewing the reports, the policy grows to this:
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self, "https://plausible.io"
policy.style_src :self, "https://fonts.googleapis.com"
policy.font_src :self, "https://fonts.gstatic.com"
policy.img_src :self, :data, "https://res.cloudinary.com"
policy.connect_src :self, "https://api.example.com", "https://plausible.io"
policy.media_src :self
policy.worker_src :self
policy.frame_src :none
policy.object_src :none
policy.base_uri :self
policy.form_action :self
policy.frame_ancestors :self
policy.report_uri "/csp-violation-report"
end
config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
config.content_security_policy_report_only = ENV.fetch("CSP_REPORT_ONLY", "true") == "true"
end
Each addition has a security cost, so here is how each one breaks down:
- Rails assets, JavaScript, and CSS: served from
:self, so no extra host is trusted. Nonces cover import map tags and Turbo's progress bar. - Stimulus: needs nothing extra, because controllers are same-origin modules.
- Fonts: trusting Google Fonts adds two hosts. Self-hosting the font files would remove them entirely and also helps privacy.
- Images: Cloudinary is trusted for images only, not for scripts.
- External API:
connect-srcallows requests to one specific host, not all of HTTPS. - Analytics: Plausible is allowed to run script and receive data. Anything it serves can execute on your pages, so choose providers you trust.
- Frames:
frame-src 'none'is kept because the app embeds nothing. Add a specific host, such as a video provider, only if you add embeds.
Step 3: Override for One Controller
Some pages need an exception, such as a page that embeds a video. Override only that controller instead of loosening the whole app:
class VideosController < ApplicationController
content_security_policy do |policy|
policy.frame_src "https://www.youtube-nocookie.com"
end
end
The block starts from the global policy and changes only frame-src for that controller. If you override a directive, include every source it needs, because the new value replaces the old one. This keeps the exception contained to one page.
Step 4: Move to Enforcement
When reports are quiet across a representative period of real traffic, set CSP_REPORT_ONLY=false in production. Keep the reporting endpoint active so you notice regressions after future deploys.
CSP Differences Between Development and Production
Development tools often need looser rules than production. A bundler dev server, live reload, or WebSocket connection may require extra connect-src entries. Rails error pages also include inline code that a strict policy may flag. Keep these exceptions out of production by building the source list conditionally:
connect_sources = [:self, "https://api.example.com"]
connect_sources << "ws://localhost:3035" if Rails.env.development?
policy.connect_src(*connect_sources)
This adds the local WebSocket address only in development. Do not use development exceptions as a template for production. Production should use exact hosts, HTTPS, and no unsafe-* keywords. It is also helpful to run report-only in development and staging, so you catch violations long before they reach users. Pair this with config.force_ssl = true in production so that your allowed HTTPS sources cannot be downgraded.
CSP for Rails APIs
A JSON-only Rails API does not render HTML, so most CSP directives have little effect there, because browsers apply CSP to documents, not to JSON responses. The policy that matters for your user-facing pages is the one on the front end that consumes the API, which must list your API host in connect-src.
If your API also serves any HTML, such as documentation or error pages, you can send a locked-down header on those responses:
config.action_dispatch.default_headers.merge!(
"Content-Security-Policy" => "default-src 'none'; frame-ancestors 'none'"
)
This tells browsers that any document served from the API cannot load resources or be framed. Verify it against your own responses before shipping, since it applies to everything the app returns.
Finding and Fixing CSP Violations
Reading Browser Developer Tools
Open your browser's developer tools and look at the Console tab. A violation looks like this:
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-...'". Either the 'unsafe-inline' keyword, a hash, or a nonce is required to enable inline execution.
The message names the blocked resource type and the directive that blocked it, which tells you where to look. In the Network tab, select the document request and open the response headers to confirm the exact policy the browser received. You can also check from the terminal:
curl -I http://localhost:3000
Look for Content-Security-Policy or Content-Security-Policy-Report-Only in the output.
Common Errors and Fixes
Symptom | Likely cause | Fix |
|---|---|---|
Inline script blocked | Missing nonce | Use |
Turbo progress bar style blocked | Missing | Add |
| Inline event handlers are blocked | Use a Stimulus action instead |
Font fails to load | Font host not allowed | Add the host to |
Fetch or API call fails | Host missing from | Add the exact API host |
Image does not render | Host or | Update |
Nonce mismatch after caching | Cached HTML contains an old nonce | Do not cache pages or fragments with nonces |
Embedded video blank |
| Allow the specific embed host |
When you fix a violation, first ask whether the resource is needed at all. Removing an unnecessary third-party script is better than allowing it.
Testing CSP Before Deploying and Strengthening It Gradually
Testing the Policy
Automated tests catch accidental regressions in the policy itself. A simple integration test can confirm the important directives are present:
require "test_helper"
class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
test "sends a content security policy header" do
get root_url
policy = response.headers["Content-Security-Policy"] ||
response.headers["Content-Security-Policy-Report-Only"]
assert_includes policy, "object-src 'none'"
assert_includes policy, "frame-ancestors 'self'"
end
end
This checks both header names so the test passes in report-only and enforced mode. Add system tests that load your key pages in a real browser to catch violations that only appear when JavaScript runs. Always test on staging with production-like settings before enabling enforcement.
Strengthening the Policy Over Time
A sensible roadmap looks like this:
- Deploy in report-only mode with a restrictive baseline.
- Fix violations by moving inline code to files or adding nonces.
- Replace broad sources like
:httpswith exact hosts. - Enforce the policy and keep reporting enabled.
- Remove
unsafe-inlineandunsafe-evalwherever they still exist. - Review the allowed hosts regularly and delete services you no longer use.
Security Best Practices and Common Mistakes
Best Practices for Production
- Start from
default-src 'self'and add exceptions only when needed. - Use nonces instead of
unsafe-inlinefor scripts. - Set
object-src 'none',base-uri 'self', andframe-ancestorsexplicitly. - Prefer exact hostnames to wildcards.
- Self-host libraries and fonts when practical, and use Subresource Integrity for CDN files.
- Keep reporting on after enforcement, and review reports.
- Combine CSP with other Rails security headers and protections, including secure cookies, HTTPS, CSRF protection, and escaped output.
Common Mistakes
- Using
:unsafe_inlineor:unsafe_evalto silence errors. - Allowing
:httpsor*inscript-src. - Reusing a session-based or fixed nonce across requests.
- Forgetting
csp_meta_tag, which breaks Turbo styling. - Caching HTML that contains nonces.
- Treating CSP as a replacement for output encoding or input validation.
- Overriding a directive in a controller and forgetting
:self. - Setting
form-actiontoo tightly and breaking redirects to external login providers. - Enabling enforcement without ever running report-only mode first.
Conclusion
A well-configured Rails Content Security Policy gives your application a strong second line of defense against XSS and other injection-based attacks. Rails makes it approachable with a built-in DSL, automatic nonces, and report-only mode, so you can start restrictive, learn from real violations, and tighten the policy gradually. Keep it narrow, avoid unsafe-inline and unsafe-eval, treat every third-party host as a trust decision, and continue to rely on output encoding, validation, and the rest of your security practices alongside it.