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

Rails Content Security Policy: A Practical Guide
Share with friends

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-src and form-action limit where scripts and forms can send data.
  • Clickjacking: frame-ancestors controls 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_tag and content_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 :self is 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, including data: URIs and favicons.
  • font-src: controls web fonts loaded through @font-face.
  • connect-src: controls fetch, 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 :none is 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 the X-Frame-Options header.

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:

  • :self allows the same origin (scheme, host, and port) as your page.
  • :none allows nothing.
  • :https allows 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.
  • :data allows data: URIs, which are common for small inline images and fonts.
  • :blob allows blob: 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_inline and :unsafe_eval relax 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-src and style-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-src allows 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 javascript_tag nonce: true or move the code to a file

Turbo progress bar style blocked

Missing csp_meta_tag

Add <%= csp_meta_tag %> to the layout head

onclick handler ignored

Inline event handlers are blocked

Use a Stimulus action instead

Font fails to load

Font host not allowed

Add the host to font-src and its stylesheet host to style-src

Fetch or API call fails

Host missing from connect-src

Add the exact API host

Image does not render

Host or data: not allowed

Update img-src

Nonce mismatch after caching

Cached HTML contains an old nonce

Do not cache pages or fragments with nonces

Embedded video blank

frame-src too strict

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:

  1. Deploy in report-only mode with a restrictive baseline.
  2. Fix violations by moving inline code to files or adding nonces.
  3. Replace broad sources like :https with exact hosts.
  4. Enforce the policy and keep reporting enabled.
  5. Remove unsafe-inline and unsafe-eval wherever they still exist.
  6. 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-inline for scripts.
  • Set object-src 'none', base-uri 'self', and frame-ancestors explicitly.
  • 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_inline or :unsafe_eval to silence errors.
  • Allowing :https or * in script-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-action too 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.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.