Web Security

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.

March 9, 20266 min readShipSafer Team

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_safe and raw reviewed and justified
  • CSP initializer configured with nonce support
  • Devise stretches >= 12, lockable enabled
  • Pundit policies for every resource
  • protect_from_forgery with: :exception in ApplicationController
  • Secrets in Rails credentials, not in code
  • bundle audit check in CI pipeline
  • config.force_ssl = true in production config

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.