Let’s talk about keeping your Rails application safe. Imagine your website is a house. You have furniture, pictures on the walls, and maybe a safe. A Content Security Policy, or CSP, is like a detailed security system for that house. Instead of just locking the front door, it specifies exactly who is allowed to bring in furniture, hang pictures, or even enter certain rooms. In web terms, it tells the browser precisely which sources are permitted to load scripts, styles, images, or other resources. This stops a very common type of attack where malicious code is injected into your site.
I think of it as moving from a simple “lock the door” approach to having a full security checklist for every item that comes in. The core mechanism is an HTTP header. Your Rails app sends this header with every response, and the browser enforces the rules you set.
Here is the most straightforward way to start. You create an initializer file in your Rails project.
# config/initializers/content_security_policy.rb
Rails.application.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
Let me explain what this does. The default_src rule is a fallback for any resource type you don’t specify. :self means the current origin—your own domain. :https means any HTTPS source. So, by default, we allow resources from our own site and secure external sources. We then define specific rules. font_src and img_src also allow :data, which is necessary for inline data URLs often used for icons or small images. The line policy.object_src :none is crucial. It completely disallows plugins like Flash, which are common attack vectors.
You might notice a problem. Many Rails applications have inline JavaScript and CSS, especially in views. With the policy above, those inline blocks would be blocked. This is a good thing security-wise, but it breaks the app. We have two main solutions: using a nonce or a hash.
A nonce is a “number used once.” It’s a random, cryptographically secure string that changes with every page request. You add it to your CSP header and also to your inline tags. The browser will only execute the inline script if the nonce in the tag matches the one in the header. Rails has built-in support for this.
First, you enable nonce generation.
# config/initializers/content_security_policy.rb
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)
This tells Rails to generate a new nonce for each request and apply it to the script-src and style-src directives. Now, you need to add this nonce to your inline code. In your layout file, it’s automatic for javascript_include_tag and stylesheet_link_tag. For true inline blocks, you use helpers.
<%# In your view template %>
<%= content_tag :script, nonce: true do %>
console.log('This inline script is allowed because it has a valid nonce.');
<% end %>
If you look at the page source, you’ll see something like <script nonce="abc123...">. The header will include script-src 'self' 'nonce-abc123...'. They match, so the script runs.
For development, you might want a more forgiving policy to avoid constant blocking while you work. You can use a report-only mode or adjust the policy.
Rails.application.config.content_security_policy do |policy|
# ... other directives ...
if Rails.env.development?
policy.script_src :self, :https, :unsafe_eval, :unsafe_inline
policy.style_src :self, :https, :unsafe_inline
else
policy.script_src :self, :https
policy.style_src :self, :https
end
end
Notice the :unsafe_inline and :unsafe_eval sources. These are, as the name suggests, not safe for production. They effectively bypass CSP for inline scripts and eval() functions. Use them only in development as a stepping stone. A better approach for development is to keep your strict policy but set up a violation reporting endpoint.
This is a critical part of the process. You don’t want to deploy a CSP and just hope it works. You need to see what it’s blocking. You can tell the browser to send reports to a URL you control.
Rails.application.config.content_security_policy do |policy|
# ... other directives ...
policy.report_uri "/csp_reports"
end
# In report-only mode for testing (doesn't block, just reports)
Rails.application.config.content_security_policy_report_only = true
Now, create a controller to handle these reports.
# app/controllers/csp_reports_controller.rb
class CspReportsController < ApplicationController
# CSP reports come without the normal authenticity token.
skip_before_action :verify_authenticity_token
def create
report = JSON.parse(request.body.read)['csp-report']
Rails.logger.warn "CSP Violation: #{report.to_json}"
# You can store this in your database for analysis
# CspViolation.create!(
# document_uri: report['document-uri'],
# violated_directive: report['violated-directive'],
# blocked_uri: report['blocked-uri']
# )
head :ok
end
end
And add a route for it.
# config/routes.rb
post '/csp_reports', to: 'csp_reports#create'
With this in place, you can deploy your application with report_only set to true. Your browser will log violations to your Rails log or database without actually blocking anything. This gives you a complete list of everything you need to fix: inline scripts, styles loaded from third-party CDNs, tracking pixels, etc.
Now let’s address external resources. A modern app uses fonts from Google, payment scripts from Stripe, analytics from somewhere else. You must explicitly allow these in your CSP. Let’s build a cleaner way to manage these sources.
# app/lib/csp/sources.rb
module Csp
module Sources
GOOGLE_FONTS = 'https://fonts.googleapis.com'.freeze
GOOGLE_STATIC = 'https://fonts.gstatic.com'.freeze
STRIPE_JS = 'https://js.stripe.com'.freeze
STRIPE_API = 'https://api.stripe.com'.freeze
GOOGLE_ANALYTICS = 'https://www.google-analytics.com'.freeze
GRAVATAR = 'https://secure.gravatar.com'.freeze
# A method to return the right sources per environment or feature
def self.for_environment(env)
base = [ :self, :https ]
case env
when 'production'
base + [ GOOGLE_FONTS, GOOGLE_STATIC, STRIPE_JS, GRAVATAR ]
when 'development'
base + [ GOOGLE_FONTS, GOOGLE_STATIC, :unsafe_eval, :unsafe_inline ]
else
base
end
end
end
end
Then, in your initializer, you can use this structured list.
Rails.application.config.content_security_policy do |policy|
env_sources = Csp::Sources.for_environment(Rails.env)
policy.default_src :self, :https
policy.font_src :self, :https, :data, Csp::Sources::GOOGLE_STATIC
policy.img_src :self, :https, :data, Csp::Sources::GRAVATAR
policy.script_src *env_sources
policy.style_src :self, :https, Csp::Sources::GOOGLE_FONTS, "'unsafe-inline'"
policy.connect_src :self, Csp::Sources::STRIPE_API
policy.frame_src :self, Csp::Sources::STRIPE_JS
end
The connect_src directive is important. It controls which URLs your JavaScript can connect to using fetch, XMLHttpRequest, or WebSockets. If you use Action Cable or live updates, you need to add your WebSocket URL here. frame_src controls where you can embed <frame>, <iframe>, or <embed> tags.
Sometimes you need a dynamic policy that changes based on the page or user. For example, an admin panel might need different rules. You can achieve this with a custom Rack middleware.
# app/middleware/dynamic_csp_middleware.rb
class DynamicCspMiddleware
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
status, headers, response = @app.call(env)
# Build a policy based on the request
csp_header = build_csp_for(request)
headers['Content-Security-Policy'] = csp_header unless csp_header.nil?
[status, headers, response]
end
private
def build_csp_for(request)
policy = ActionDispatch::ContentSecurityPolicy.new
# Base policy
policy.default_src :self, :https
policy.script_src :self, :https
# Add Stripe on checkout pages
if request.path.include?('/checkout')
policy.script_src :self, :https, 'https://js.stripe.com'
policy.frame_src 'https://js.stripe.com'
end
# Allow a specific inline script for this page using a hash
if request.path == '/special-widget'
# The hash is the SHA256 of the exact script content
policy.script_src :self, :https, "'sha256-abc123...'"
end
policy.build
end
end
You need to register this middleware.
# config/application.rb
module YourApp
class Application < Rails::Application
config.middleware.use DynamicCspMiddleware
end
end
This gives you immense flexibility. The hash method I used for the ‘/special-widget’ page is an alternative to nonces. You calculate the SHA256 hash of the entire inline script block and add it to the script-src directive. The browser will compute the hash of the script and execute it only if they match. It’s less flexible than a nonce because the script content can never change without updating the hash, but it’s useful for static, critical scripts.
Managing all of this can become complex. I find it helpful to create a simple dashboard to see violations.
# app/controllers/admin/csp_dashboard_controller.rb
module Admin
class CspDashboardController < ApplicationController
before_action :authorize_admin
def index
# Assuming you stored violations in a CspViolation model
@recent_violations = CspViolation.order(created_at: :desc).limit(50)
@summary = {
last_24_hours: CspViolation.where('created_at > ?', 24.hours.ago).count,
top_blocked_uri: CspViolation.group(:blocked_uri).order('count_all desc').limit(5).count,
top_directive: CspViolation.group(:violated_directive).order('count_all desc').limit(5).count
}
end
end
end
This dashboard helps you see patterns. Maybe you’re constantly blocking a useful browser extension your team uses, or maybe you’re seeing probing attacks from specific sources.
Finally, let’s talk about testing. You should not deploy a CSP without tests. Here’s how you can write a system test to ensure your policy is active and working.
# test/system/csp_test.rb
require 'application_system_test_case'
class CspTest < ApplicationSystemTestCase
test 'CSP header is present' do
visit root_url
csp_header = page.response_headers['Content-Security-Policy']
assert csp_header.present?, 'CSP header is missing'
assert_includes csp_header, 'script-src', 'CSP header does not contain script-src directive'
end
test 'inline script is blocked without nonce' do
# This test requires a page with a known, nonced script.
visit home_url
script_tags = page.all('script', visible: false)
script_tags.each do |script|
# Every script tag should have a nonce attribute
assert script[:nonce].present?, 'Found a script tag without a nonce attribute'
end
end
end
You can also use tools like the secure_headers gem, which provides more advanced CSP features and easier management, but understanding the manual setup gives you a firm grasp of the principles.
The journey to a full CSP is incremental. Start with report-only mode. Analyze the logs. Fix the issues one by one, either by adding external sources to your policy, moving inline code to external files, or adding nonces. Then, switch from report-only to enforcing. Continue to monitor the reports. It’s a powerful layer of security that directly tells the browser what your application’s rules are, making it exponentially harder for attackers to inject malicious content. It’s not just a configuration; it’s a fundamental shift in how your application defines its trust boundaries.