>_rails inside
~/rails-inside $ cat blog/security-best-practices-rails-developers.html
HomePostsAbout
// 01  SECURITY  ////  Apr 21, 2026  ////  8 min

Essential Security Best Practices for Rails Developers

A practical Rails security checklist covering SQL injection, mass assignment, CSRF, secrets management, dependency auditing, and secure headers.

Rails ships with a strong security baseline. CSRF protection, SQL parameterization, and the secret_key_base infrastructure are all on by default. But defaults get turned off, unsafe patterns creep in during deadline-driven sprints, and dependencies accumulate vulnerabilities over time. Security work in a Rails application is less about learning exotic attack vectors and more about auditing whether the defaults are intact and whether the edges have been covered.

This guide covers the categories that account for the majority of real-world Rails vulnerabilities, with concrete code patterns for each.

SQL Injection

Active Record parameterizes queries automatically when you use the query interface correctly. The vulnerability appears when developers interpolate user input into query strings:

# Vulnerable, never do this
User.where("email = '#{params[:email]}'")

# Safe, parameterized
User.where(email: params[:email])
User.where("email = ?", params[:email])
User.where("email = :email", email: params[:email])

The order clause is a common injection point that developers miss. User-controlled sort parameters must be filtered through an allowlist:

ALLOWED_SORT_COLUMNS = %w[created_at updated_at name].freeze

def sort_column
  ALLOWED_SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "created_at"
end

@users = User.order("#{sort_column} #{sort_direction}")

The brakeman static analysis gem catches most SQL injection patterns at CI time:

gem install brakeman
brakeman --rails7

Run Brakeman in your CI pipeline and treat its high-confidence findings as build failures, not warnings.

Mass Assignment and Strong Parameters

attr_accessible was replaced by Strong Parameters in Rails 4. Legacy codebases sometimes still use attr_accessible via a compatibility shim or skip Strong Parameters entirely on internal APIs that were never meant to be public but became so.

Every controller action that accepts form input needs an explicit permit list:

def user_params
  params.require(:user).permit(:email, :name, :time_zone)
end

Never use params.require(:user).permit!. The bang form permits all attributes, which means an attacker can write to admin, role, or any other column by crafting a request body. The extra three minutes of typing a permit list is the entire cost of preventing privilege escalation.

CSRF Protection

Rails includes CSRF protection by default through protect_from_forgery. It should be enabled in ApplicationController with the :exception strategy in production, which raises an error rather than silently resetting the session:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end

API-only controllers that inherit from ActionController::API do not include CSRF protection, which is correct. If you have a hybrid application where some API endpoints are accessed by browser sessions, add the CSRF module explicitly to those controllers:

class HybridController < ActionController::API
  include ActionController::RequestForgeryProtection
  protect_from_forgery with: :exception
end

Secrets Management

Credentials in environment variables are better than credentials hardcoded in source files, but Rails encrypted credentials (rails credentials:edit) are better still for most teams. The master key stays out of source control and each deployed environment can have its own credential set:

# Edit credentials
EDITOR="code --wait" rails credentials:edit

# Access in application code
Rails.application.credentials.dig(:aws, :access_key_id)

For teams operating at larger scale or with strict compliance requirements, integrate with a secrets manager, AWS Secrets Manager, HashiCorp Vault, or 1Password Secrets Automation, and pull credentials at boot rather than embedding them in the credentials file. The dotenv-rails gem is fine for local development; it should never be the secrets strategy in production.

Dependency Auditing

The bundler-audit gem checks your Gemfile.lock against the Ruby Advisory Database and reports known CVEs:

gem install bundler-audit
bundle audit check --update

This check belongs in CI, not just in developer laptops. A dependency with a known critical vulnerability that ships to production because no one ran an audit is an avoidable incident.

# .github/workflows/security.yml
- name: Bundle Audit
  run: |
    gem install bundler-audit
    bundle audit check --update

Keep the advisory database current by running bundle audit update before each check. Stale databases produce false negatives.

Secure HTTP Headers

The secure_headers gem configures Content Security Policy, X-Frame-Options, X-Content-Type-Options, and HSTS headers through a single initializer:

# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
  config.hsts = "max-age=#{1.year.to_i}"
  config.x_frame_options = "DENY"
  config.x_content_type_options = "nosniff"
  config.x_xss_protection = "0"
  config.csp = {
    default_src: %w['none'],
    script_src: %w['self'],
    style_src: %w['self'],
    img_src: %w['self' data:],
    connect_src: %w['self'],
    font_src: %w['self'],
    form_action: %w['self'],
    frame_ancestors: %w['none'],
    upgrade_insecure_requests: true
  }
end

Start with a permissive CSP in report-only mode using config.csp_report_only and tighten it over two or three sprints as you identify what legitimate resources the application loads.

Enumeration and Rate Limiting

Timing-consistent authentication responses prevent user enumeration. Devise handles this correctly by default. If you implement custom authentication, ensure that both "user not found" and "wrong password" paths take the same amount of time. Rack::Attack handles rate limiting without adding a full API gateway:

# config/initializers/rack_attack.rb
Rack::Attack.throttle("logins by ip", limit: 5, period: 60) do |req|
  req.ip if req.path == "/users/sign_in" && req.post?
end

Rack::Attack.throttle("logins by email", limit: 5, period: 60) do |req|
  if req.path == "/users/sign_in" && req.post?
    req.params["user"]["email"].to_s.downcase.gsub(/\s+/, "")
  end
end

Both the IP-based and identifier-based throttles are necessary. IP-based limits alone are bypassable with a botnet; identifier-based limits alone leak whether an email address exists.

Auditing What You Have

The Rails Security Guide maintained by the core team is the canonical reference for vulnerabilities specific to the framework. Run a full Brakeman scan, a bundle audit, and a header check with the http_observatory CLI against your staging environment quarterly. Most Rails security incidents involve a known vulnerability class, not a novel exploit. Consistent auditing closes the gap.

For the gems referenced in this guide, see The Best Ruby on Rails Plugins for Modern Web Development. For improving the underlying code structure that security vulnerabilities often hide in, see How to Refactor Legacy Rails Applications for Performance.