We've all seen the headlines: a production breach, stolen customer data, and a very expensive incident review. Rails gives us solid security foundations out of the box, but defaults alone won't protect us. This Rails security checklist: a hands-on guide for engineers walks you through the vulnerabilities we encounter most often in production systems and the practical fixes that actually work.
SQL injection — Use parameterized queries, never string interpolation
SQL injection remains one of the easiest ways for an attacker to extract or corrupt your entire database. The fix is straightforward: never concatenate user input directly into SQL strings. Rails makes this simple if we follow the pattern consistently.
Here's the dangerous approach we've all seen (and hopefully never shipped):
# DON'T DO THIS
user = User.find_by("email = '#{params[:email]}'")
An attacker passing ' OR '1'='1 as the email would bypass authentication entirely. The correct approach uses parameterized queries:
# DO THIS
user = User.find_by(email: params[:email])
# Or explicitly:
user = User.where("email = ?", params[:email]).first
Rails automatically handles the escaping when you use the hash syntax or bind parameters with the ? placeholder. The ActiveRecord query builder is your friend here—it parameterizes by default when you chain methods like .where, .find_by, and .order (when passed a hash).
However, raw SQL queries need extra care:
# RISKY - only use if params is guaranteed safe
User.find_by_sql("SELECT * FROM users WHERE email = '#{params[:email]}'")
# SAFE - use bind variables
User.find_by_sql(["SELECT * FROM users WHERE email = ?", params[:email]])
We recommend treating any dynamic SQL construction as a security gate. Code review these carefully, and consider using Brakeman (covered below) to catch these patterns automatically.
XSS — ERB escapes by default; avoid raw and html_safe
Cross-Site Scripting (XSS) happens when untrusted user data gets rendered as HTML or JavaScript in the browser. A malicious user could inject a script tag that steals session tokens or redirects visitors to a phishing site.
Rails ERB templates escape HTML entities by default, which is the right move:
# In your view (app/views/posts/show.html.erb)
<h1><%= @post.title %></h1>
<p><%= @post.description %></p>
If a user submits a title like <script>alert('hacked')</script>, ERB will render it as escaped HTML: <script>alert('hacked')</script>. The browser displays the text safely instead of executing the script.
The danger emerges when we override this safety. Two methods bypass escaping and should rarely appear in your codebase:
# DANGEROUS - renders HTML without escaping
<%= raw(@post.description) %>
<%= @post.description.html_safe %>
We've seen production incidents where user-submitted content was marked html_safe to preserve formatting, opening the door to injected malware. If you genuinely need to allow rich HTML (like formatted blog content from trusted editors), sanitize it first:
# Gemfile
gem 'rails-html-sanitizer'
# In your model or controller
class Post < ApplicationRecord
def safe_description
Rails::Html::FullSanitizer.new.sanitize(description)
end
end
For user-generated content, consider whitelisting safe tags:
# config/initializers/sanitizers.rb
ALLOWED_TAGS = %w[b i strong em p br ul ol li a].freeze
ALLOWED_ATTRIBUTES = %w[href].freeze
def sanitize_user_html(html)
Rails::Html::Sanitizer.full_sanitizer.sanitize(
html,
tags: ALLOWED_TAGS,
attributes: ALLOWED_ATTRIBUTES
)
end
In JavaScript-heavy applications (especially those using Next.js or React alongside Rails), remember that <%= %> escaping only protects HTML context. JSON responses need different handling. Always use libraries like json gem that automatically escape dangerous characters when you return JSON from Rails.
CSRF — Protection is on by default for forms
Cross-Site Request Forgery allows an attacker to trick a logged-in user into performing unwanted actions. For example, a malicious site could submit a hidden form that transfers money from your banking app.
Rails protects against CSRF by default. Every form generated with the form_with or form_for` helpers automatically includes a CSRF token:
# In app/views/posts/new.html.erb
<%= form_with(model: @post, local: true) do |form| %>
<%= form.text_field :title %>
<%= form.submit %>
<% end %>
# This renders an hidden input:
# <input type="hidden" name="authenticity_token" value="abc123..." />
Rails checks this token on POST, PUT, PATCH, and DELETE requests and rejects any request that lacks a valid token. You rarely need to think about it, but you should understand that it's there.
CSRF protection can be disabled (often when building APIs), and that's appropriate for stateless token-based authentication:
# config/initializers/cors.rb or in your controller
class Api::PostsController < ApplicationController
skip_before_action :verify_authenticity_token
# Now use Bearer tokens or API keys instead
end
If you're building a Rails API consumed by a separate frontend (React, Next.js, mobile app), disable CSRF protection for API routes but keep it on for your web views. Make sure your API uses strong authentication—API keys, OAuth tokens, or JWT with secure storage.
One gotcha: AJAX requests need the token too. If you're fetching with JavaScript, include the token in your request headers:
# In your JavaScript (using fetch)
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/posts', {
method: 'POST',
headers: {
'X-CSRF-Token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: 'New post' })
});
Brakeman — Static analysis for security issues in CI
Brakeman scans your Rails codebase for common vulnerabilities without running it. It's fast, catches real issues, and integrates seamlessly into your CI pipeline. We consider it essential.
Install it in your Gemfile:
# Gemfile
group :development, :test do
gem 'brakeman', require: false
end
Run it locally:
bundle exec brakeman
It outputs a report showing potential SQL injection, XSS, unsafe redirects, and more. Many false positives exist (Brakeman is conservative), but genuine issues almost always appear. Add it to your CI pipeline:
# .github/workflows/security.yml
name: Security
on: [push, pull_request]
jobs:
brakeman:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
- run: bundle install
- run: bundle exec brakeman -q -z
The -z flag exits with a non-zero code if vulnerabilities are found, failing the build. Configure Brakeman to ignore known false positives in config/brakeman.yml:
# config/brakeman.yml
ignored_warnings:
- type: "Redirect"
file: "app/controllers/users_controller.rb"
line: 45
Beyond Brakeman, regularly run bundle audit to check for known vulnerabilities in your dependencies:
bundle audit check --update
Common mistakes
- String interpolation in queries: New team members sometimes interpolate user input directly into Active Record queries. Enforce parameterized queries in code review and catch them with Brakeman.
- Marking user content as html_safe: We've seen this done to preserve formatting or fix rendering bugs. Sanitize instead of marking safe.
- Disabling CSRF protection globally: Disable it only for API routes that use proper token-based auth. Never disable it for your web forms.
- Ignoring Brakeman warnings: Every warning should be investigated. Dismissing them without understanding the risk invites real vulnerabilities.
- Relying on client-side validation: Never trust the browser. Validate and sanitize all user input server-side, always.
- Storing secrets in code: Rails credentials and environment variables exist for a reason. Never commit database passwords or API keys to git.
Key takeaways
- Use parameterized queries everywhere. Rails makes this the default when you use the query builder (hash syntax) or bind variables with
?placeholders. - Trust ERB's built-in escaping. Avoid
raw()andhtml_safeunless you've explicitly sanitized the content first. - CSRF protection is on by default. Disable it only for API endpoints with proper token-based authentication.
- Run Brakeman in CI on every push. It catches security issues before they reach production.
- Treat security as a team responsibility. Educate developers on these patterns, make them the default in code review, and automate detection where possible.






Comments
Loading comments…