Ruby on Rails Security Guide: Authentication, SQL Injection, and XSS Prevention
Secure Ruby on Rails applications with Strong Parameters, ActiveRecord parameterization, mass assignment protection, Devise config, and CSP DSL.
Ruby on Rails Security Guide: Authentication, SQL Injection, and XSS Prevention
Rails ships with a remarkable number of security protections built in. The challenge is knowing which ones to configure beyond their defaults, which edge cases bypass them, and how to layer additional controls for production-grade security. This guide covers the essential patterns every Rails developer should know.
Strong Parameters: Mass Assignment Protection
Before Rails 4, attackers could send arbitrary parameters in a POST request and overwrite protected fields — the infamous GitHub incident involved setting admin: true via mass assignment. Strong Parameters, now built into Rails, requires you to explicitly permit every field:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
redirect_to @user
else
render :new
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation)
# Note: never permit :admin, :role, :confirmed_at, etc.
end
end
Common mistakes:
- Using
params.require(:user).permit!— this permits all attributes and defeats the purpose - Permitting nested attributes without careful scoping
- Forgetting to remove sensitive fields from API responses
# DANGEROUS — permits everything
def user_params
params.require(:user).permit!
end
SQL Injection Prevention with ActiveRecord
ActiveRecord parameterizes queries automatically when you use the standard query interface:
# Safe — parameterized
User.where(email: params[:email])
User.where("created_at > ?", 1.week.ago)
User.where("role = :role", role: params[:role])
# DANGEROUS — string interpolation
User.where("email = '#{params[:email]}'") # SQL injection
User.order("#{params[:sort_column]} ASC") # SQL injection in ORDER BY
The order clause is a frequent blind spot. Always validate sort columns against an allowlist:
SORTABLE_COLUMNS = %w[name email created_at].freeze
def safe_order(column, direction)
col = SORTABLE_COLUMNS.include?(column) ? column : 'created_at'
dir = %w[asc desc].include?(direction.downcase) ? direction.downcase : 'desc'
"#{col} #{dir}"
end
User.order(safe_order(params[:sort], params[:direction]))
Raw SQL with find_by_sql and execute must use bind parameters:
# Safe
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(["SELECT * FROM users WHERE email = ?", email])
)
XSS Prevention
Rails ERB templates auto-escape output by default. The escape hatch is html_safe and raw:
<!-- Safe — auto-escaped -->
<p><%= @user.bio %></p>
<!-- DANGEROUS — raw HTML from user input -->
<p><%= @user.bio.html_safe %></p>
<p><%= raw @user.bio %></p>
If you need to render rich text from users, use the rails-html-sanitizer gem (already included in Rails) or ActionText:
# config/application.rb or an initializer
ActionView::Base.sanitized_allowed_tags = %w[
strong em b i p br ul ol li blockquote a
]
ActionView::Base.sanitized_allowed_attributes = %w[href class]
<!-- In views -->
<%= sanitize @post.content %>
For user-generated content that should allow a rich editing experience, use ActionText which handles sanitization automatically.
Content Security Policy DSL
Rails 5.2+ includes a built-in CSP DSL in config/initializers/content_security_policy.rb:
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
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, :unsafe_inline
policy.frame_ancestors :none
# Report violations
policy.report_uri '/csp-violations'
end
# Enable nonce for inline scripts
Rails.application.config.content_security_policy_nonce_generator = ->(_request) {
SecureRandom.base64(16)
}
Rails.application.config.content_security_policy_nonce_directives = %w[script-src]
Use the nonce in views:
<%= javascript_tag nonce: true do %>
console.log("This inline script has a nonce");
<% end %>
Authentication with Devise
Devise is the standard Rails authentication library. Key security configuration options:
# config/initializers/devise.rb
Devise.setup do |config|
# Stretch factor for bcrypt — higher is slower and more secure
config.stretches = Rails.env.test? ? 1 : 12
# Lock accounts after failed attempts
config.lock_strategy = :failed_attempts
config.maximum_attempts = 5
config.unlock_strategy = :both # :email or :time or :both
config.unlock_in = 1.hour
# Password length
config.password_length = 12..128
# Session timeout
config.timeout_in = 30.minutes
# Track sign-in IP and time
config.track_sign_in = true
# Secure reset tokens
config.reset_password_within = 2.hours
# Require email confirmation
config.reconfirmable = true
end
Add the :lockable and :trackable modules to your User model:
class User < ApplicationRecord
devise :database_authenticatable, :registerable, :recoverable,
:rememberable, :validatable, :confirmable, :lockable, :trackable
end
Authorization with Pundit
Authentication (who are you?) is different from authorization (what can you do?). Use Pundit for policy-based authorization:
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def update?
record.user == user || user.admin?
end
def destroy?
record.user == user || user.admin?
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def update
@post = Post.find(params[:id])
authorize @post # raises Pundit::NotAuthorizedError if policy fails
@post.update!(post_params)
end
end
Call verify_authorized in your ApplicationController to ensure you never accidentally forget to authorize an action:
class ApplicationController < ActionController::Base
include Pundit::Authorization
after_action :verify_authorized, except: :index
end
CSRF Protection
Rails enables CSRF protection by default via protect_from_forgery. Understand the modes:
class ApplicationController < ActionController::Base
# Raises exception on CSRF failure — best for production
protect_from_forgery with: :exception
# For JSON APIs with token auth, use :null_session instead
# protect_from_forgery with: :null_session
end
For API controllers that use token-based authentication and never use cookies, you can skip CSRF:
class Api::BaseController < ActionController::API
# ActionController::API does not include CSRF protection by default
# Verify your auth token in before_action instead
before_action :authenticate_api_token!
end
Secrets and Credentials
Never hardcode secrets. Rails 5.2+ includes encrypted credentials:
# Edit encrypted credentials
EDITOR=vim rails credentials:edit
# Access in code
Rails.application.credentials.stripe_secret_key
Rails.application.credentials.dig(:aws, :access_key_id)
For multi-environment setups:
rails credentials:edit --environment production
Keep config/master.key out of version control. It should already be in .gitignore.
Security Headers with secure_headers Gem
For more granular header control beyond Rails defaults:
# Gemfile
gem 'secure_headers'
# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
config.hsts = "max-age=#{1.year}; includeSubDomains; preload"
config.x_frame_options = "DENY"
config.x_content_type_options = "nosniff"
config.referrer_policy = "strict-origin-when-cross-origin"
end
Dependency Auditing
# Check for known vulnerabilities
bundle audit check --update
# Or use bundler-audit in CI
gem install bundler-audit
bundle-audit update
bundle-audit check
Add this to your CI pipeline and fail the build on any high-severity findings.
Quick Security Checklist
- Strong Parameters in all controllers — no
.permit! - No string interpolation in ActiveRecord queries
-
html_safeandrawreviewed and justified - CSP initializer configured with nonce support
- Devise
stretches >= 12, lockable enabled - Pundit policies for every resource
-
protect_from_forgery with: :exceptionin ApplicationController - Secrets in Rails credentials, not in code
-
bundle audit checkin CI pipeline -
config.force_ssl = truein production config