The go-to resource for upgrading Ruby, Rails, and your dependencies.

Improving Frontend Security with Strict Content Security Policies in Rails 8


Learn how to mitigate Cross-Site Scripting (XSS) and meet compliance requirements using nonce-based CSP in Rails 8.

The Economics of Modernization: Rethinking Frontend Security

In the early days of aviation, security was largely an afterthought; passengers could stroll directly to the departure gate without passing through a scanner. As threats evolved, however, airports implemented strict checkpoints, verifying every passenger and every piece of luggage before allowing them into the secure zone.

For a long time, web browsers operated much like those early airports. If an HTML document included a <script> tag, the browser would execute it — no questions asked. This implicit trust is the root cause of Cross-Site Scripting (XSS), a vulnerability where an attacker injects malicious scripts into a trusted web application.

Historically, developers attempted to mitigate XSS by exhaustively sanitizing user input. This approach — often referred to as playing “whack-a-mole” — is fragile. Miss one input field, and the application is compromised. Furthermore, as applications grow in complexity and integrate numerous third-party dependencies, guaranteeing that every piece of data is perfectly sanitized becomes an operational impossibility.

A more robust solution came in the form of the Content Security Policy (CSP). A CSP is an HTTP response header that allows site administrators to declare approved sources of content that the browser may load.

Implementing a strict CSP is no longer a best practice; it is often a strict requirement for organizations pursuing SOC2, HIPAA, or PCI DSS compliance. When planning a Ruby on Rails upgrade or addressing the findings of a security audit, configuring a modern CSP is a high-value strategy for reducing long-term technical debt and organizational risk.

Understanding the Architectural Shift: From Allowlists to Strict CSP

Rails has supported Content Security Policies since Rails 5.2. Early implementations, though, typically relied on domain allowlists. You would explicitly list the domains permitted to serve scripts to your application:

policy.script_src :self, "https://www.google-analytics.com", "https://js.stripe.com"

This isn’t completely false security, and it’s often in this sense of the word that people first learn CSP. The problem with this definition, though, is that domain allowlists have proven to be surprisingly vulnerable. If an attacker finds an open redirect or a JSONP endpoint on an allowed domain, they can often bypass the policy entirely.

Modern frontend security, therefore, relies on a “Strict CSP.” Rather than trusting entire domains, a Strict CSP trusts specific script tags using a cryptographic nonce (a number used once), combined with the strict-dynamic directive.

Let’s walk through how to build a Strict CSP in Rails 8, moving from a basic configuration to a fully secured, nonce-based implementation.

Step 1: Establishing the Baseline Policy

In Rails 8, the Content Security Policy is configured via an initializer. If you generated a new Rails 8 application, you will find a commented-out template in config/initializers/content_security_policy.rb.

Let’s define a restrictive baseline. The following configuration denies everything by default, and then explicitly allows only essential resources from our own domain:

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :none
    policy.font_src    :self, :data
    policy.img_src     :self, :data
    policy.object_src  :none
    policy.script_src  :self
    policy.style_src   :self
    policy.connect_src :self
    
    # Specify URI for violation reports
    # policy.report_uri "/csp-violation-report-endpoint"
  end
end

By setting default_src :none, we ensure that if we forget to configure a specific directive, the browser will default to the most secure posture: blocking the resource.

Step 2: Implementing Cryptographic Nonces

Strictly speaking, the above policy will break most modern web applications. It prevents the execution of any inline JavaScript, including the scripts often generated by modern frontend tooling or Hotwire/Turbo.

To safely allow inline scripts, we implement nonces. A nonce is a randomly generated token uniquely created for every single HTTP response. The server includes this token in the CSP header, and we attach the exact same token to any <script> tags we want the browser to execute.

First, let’s enable nonce generation in our initializer:

Rails.application.configure do
  config.content_security_policy do |policy|
    # ... snip ...
    policy.script_src  :self
  end

  # Generate session nonces for permitted importmap, inline scripts, and inline styles.
  config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
  config.content_security_policy_nonce_directives = %w(script-src style-src)
end

Now, when writing views, we must explicitly declare our trusted scripts using the nonce: true option:

<%= javascript_tag nonce: true do %>
  console.log("This script is trusted and will execute.");
<% end %>

If an attacker manages to inject a malicious <script>alert('XSS')</script> tag into the page via a vulnerability, the browser will refuse to execute it because the injected tag lacks the matching cryptographic nonce for that specific request.

Step 3: The Power of strict-dynamic

Of course, finding and noncing every single script tag in a large application is part of the battle. Modern applications frequently rely on third-party scripts — like analytics trackers or payment gateways — which dynamically inject additional scripts into the DOM.

Because these secondary scripts are injected by JavaScript after the page loads, they won’t have the server-generated nonce. A standard nonce-based policy would block them, breaking your integrations.

This leads naturally to our next directive: strict-dynamic.

The strict-dynamic directive tells modern browsers (CSP Level 3 and above) to implicitly trust any script that was loaded by an already-trusted, nonced script. It allows trust to propagate.

Let’s update our initializer to use the modern strict CSP pattern:

Rails.application.configure do
  config.content_security_policy do |policy|
    # ... snip ...
    
    # The Strict CSP Pattern
    policy.script_src :strict_dynamic, :nonce
  end
end

When a browser encounters this policy, it ignores domain allowlists entirely. It will only execute scripts that possess a valid nonce, or scripts dynamically added by a nonced script. This effectively neutralizes XSS while allowing modern tag managers and complex frontend integrations to function normally.

The Cautionary Scope: Using Report-Only Mode

A Content Security Policy is a powerful tool. However, applying a strict CSP to a legacy Rails application will almost certainly break functionality. You will invariably discover undocumented inline scripts, inline event handlers (like onclick="doSomething()"), and legacy libraries utilizing eval().

Therefore, before you enforce any of the directives in this chapter, it is extremely wise to use Rails’ report_only mode.

Rails.application.configure do
  # ... snip ...
  
  # Report CSP violations to a specified URI without enforcing the policy.
  config.content_security_policy_report_only = true
end

When report_only is true, the browser will not block any scripts. Instead, it will silently send JSON reports to your report_uri detailing exactly what would have been blocked. You can collect these reports using error tracking software like Sentry or Honeybadger, analyze them, and systematically refactor your legacy views to use nonces or external files.

Only after the violation reports drop to zero should you toggle report_only to false and enforce the policy.

Trade-Offs and Long-Term Implications

Implementing a Strict Content Security Policy in Rails 8 requires upfront engineering effort. You must refactor inline event handlers, verify your third-party integrations, and closely monitor violation reports.

The cost of maintaining software, of course, brings us to our primary motivation: reducing long-term risk. By shifting from a reactive “sanitize everything” mindset to a proactive, nonce-based execution policy, you systematically eliminate an entire class of vulnerabilities. This not only protects your users but ensures your infrastructure cleanly passes compliance audits, leaving your engineering team free to focus on product development rather than playing security whack-a-mole.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming