Securing Your Rails Application: A Practical Guide

Rails projects' security is not automatic. It requires constant attention

Securing Your Rails Application: A Practical Guide

You've built your Rails app. It works. Users are signing up. Data is flowing.

But is it secure?

Most developers assume Rails protects them by default. It does, to some extent. But security is not automatic. It requires constant attention.

Let me show you how to secure your Rails application against real threats.


The Short Answer

Rails gives you a solid security foundation. But you need to build on it.

Threat Rails Protection Your Responsibility
SQL Injection Strong Use parameterized queries
XSS Strong Sanitize user input
CSRF Built-in Keep it enabled
Mass Assignment Strong Use strong parameters
Authentication None Implement properly
Authorization None Implement properly

Don't assume Rails protects you from everything. It doesn't.


Part 1: Authentication

Authentication is the first line of defense. Do it right.

Use Devise or Built-in Authentication

Rails 7 includes built-in authentication:

BASH
rails generate authentication

This gives you:

  • Secure password hashing (bcrypt)
  • Session management
  • Password reset flow

If you need more features, use Devise:

RUBY
# Gemfile
gem "devise", "~> 4.9"
BASH
rails generate devise:install
rails generate devise User
rails db:migrate

Don't Roll Your Own Auth

Never write your own authentication from scratch.

RUBY
# ❌ Don't do this
class UsersController < ApplicationController
  def create
    user = User.new(params)
    if params[:password] == params[:password_confirmation]
      # ... bad security
    end
  end
end

# ✅ Use a battle-tested solution
# Devise or Rails built-in authentication

Secure Password Storage

RUBY
# Gemfile
gem "bcrypt", "~> 3.1"
RUBY
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password

  validates :password, length: { minimum: 8 }, if: -> { password.present? }
end

Rate Limiting Login Attempts

RUBY
# config/initializers/rack_attack.rb
class Rack::Attack
  throttle("login/ip", limit: 20, period: 1.minute) do |req|
    req.ip if req.path == "/users/sign_in" && req.post?
  end

  throttle("login/email", limit: 5, period: 1.hour) do |req|
    req.params["user"]["email"] if req.path == "/users/sign_in" && req.post?
  end
end

Part 2: Authorization

Authentication tells you who the user is. Authorization tells you what they can do.

Use Pundit or CanCanCan

Pundit is simple and works well:

RUBY
# Gemfile
gem "pundit", "~> 2.3"
BASH
rails generate pundit:install
RUBY
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
  def update?
    user.admin? || user == record.user
  end

  def destroy?
    user.admin? || user == record.user
  end
end
RUBY
# app/controllers/posts_controller.rb
def update
  authorize @post
  @post.update(post_params)
end

Check Authorization in Views

ERB
<% if policy(@post).update? %>
  <%= link_to "Edit", edit_post_path(@post) %>
<% end %>

Don't Rely on Hide/Show

Never rely on hiding UI elements for security. Always authorize on the server side.

RUBY
# ❌ Bad - hiding button only
<% if current_user.admin? %>
  <%= link_to "Delete", post_path(@post), method: :delete %>
<% end %>

# ✅ Good - authorize on server
class PostsController < ApplicationController
  def destroy
    authorize @post
    @post.destroy
  end
end

Part 3: SQL Injection

Rails protects you from SQL injection... if you use it correctly.

Use Parameterized Queries

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

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

# ✅ Safe - Rails style
User.where(email: params[:email])

Be Careful with Raw SQL

RUBY
# ❌ Vulnerable if user input is included
User.find_by_sql("SELECT * FROM users WHERE email = '#{params[:email]}'")

# ✅ Safe
User.find_by_sql(["SELECT * FROM users WHERE email = ?", params[:email]])

Use Sanitize for Dynamic Table/Column Names

RUBY
# If you must use dynamic column names
order = params[:sort] || "created_at"
direction = params[:direction] || "desc"

# Sanitize column name
allowed_columns = ["created_at", "updated_at", "name"]
order = allowed_columns.include?(order) ? order : "created_at"

direction = direction == "desc" ? "desc" : "asc"

User.order("#{order} #{direction}")

Part 4: Cross-Site Scripting (XSS)

Rails protects you from XSS... if you use it correctly.

Use .html_safe Carefully

ERB
# ❌ Dangerous - marks user input as safe
<%= user_input.html_safe %>

# ✅ Safe - sanitizes the input
<%= sanitize(user_input) %>

# ✅ Safe - Rails escapes by default
<%= user_input %>

Sanitize User-Provided HTML

RUBY
# Allow only specific tags
sanitized = sanitize(user_input, tags: %w[strong em a], attributes: %w[href])

# Use ActionText for rich content
has_rich_text :content  # Automatically sanitizes

Escape in JavaScript

ERB
# ❌ Dangerous
<script>
  var userData = <%= user_input.to_json.html_safe %>;
</script>

# ✅ Safe
<script>
  var userData = <%= user_input.to_json %>;
</script>

Part 5: Cross-Site Request Forgery (CSRF)

Rails has CSRF protection built in. Keep it enabled.

Keep CSRF Protection On

RUBY
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Keep this enabled
  protect_from_forgery with: :exception
end

Include CSRF Token in Forms

ERB
<%= form_with model: @post do |f| %>
  <!-- CSRF token is automatically included -->
<% end %>

<!-- For AJAX requests -->
<%= csrf_meta_tags %>
JAVASCRIPT
// Include token in AJAX requests
document.querySelector('meta[name="csrf-token"]').getAttribute('content')

Part 6: Mass Assignment

Rails protects you from mass assignment... if you use strong parameters.

Use Strong Parameters

RUBY
# ❌ Vulnerable - allows all attributes
User.create(params[:user])

# ✅ Safe - allows only specific attributes
User.create(user_params)

private

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

Never Use without_protection

RUBY
# ❌ Dangerous
User.create(params[:user], without_protection: true)

# ❌ Dangerous in Rails 4+
User.create(params[:user]) # Without strong params

Part 7: Secure Headers

Add security headers to protect against common attacks.

Use Secure Headers Gem

RUBY
# Gemfile
gem "secure_headers", "~> 6.5"
BASH
bundle install
RUBY
# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
  config.hsts = "max-age=31536000; includeSubdomains; preload"
  config.x_frame_options = "DENY"
  config.x_content_type_options = "nosniff"
  config.x_xss_protection = "1; mode=block"
  config.referrer_policy = "strict-origin-when-cross-origin"

  config.csp = {
    default_src: ["'self'"],
    script_src: ["'self'", "'unsafe-inline'"],
    style_src: ["'self'", "'unsafe-inline'"],
    img_src: ["'self'", "https:"],
    font_src: ["'self'"],
    connect_src: ["'self'"]
  }
end

Manual Headers in Rails

RUBY
# config/initializers/security_headers.rb
Rails.application.config.action_dispatch.default_headers = {
  "X-Frame-Options" => "DENY",
  "X-Content-Type-Options" => "nosniff",
  "X-XSS-Protection" => "1; mode=block",
  "Referrer-Policy" => "strict-origin-when-cross-origin"
}

Part 8: Session Security

Secure your sessions.

Use a Secure Session Store

RUBY
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: "_myapp_session",
  secure: Rails.env.production?,
  httponly: true,
  same_site: :lax

Regenerate Session ID on Login

RUBY
class SessionsController < ApplicationController
  def create
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      # Regenerate session ID
      reset_session
      session[:user_id] = user.id
      redirect_to dashboard_path
    else
      render :new
    end
  end
end

Set Session Timeout

RUBY
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  expire_after: 24.hours

Part 9: Security Scanning Tools

Automate security testing.

Brakeman

RUBY
# Gemfile
group :development do
  gem "brakeman"
end
BASH
brakeman -o security_report.html

Bundle Audit

RUBY
# Gemfile
group :development do
  gem "bundler-audit"
end
BASH
bundle audit

Dependabot

Enable Dependabot on GitHub to automatically update vulnerable dependencies.

YAML
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "bundler"
    directory: "/"
    schedule:
      interval: "weekly"

Part 10: Monitoring and Logging

Monitor for suspicious activity.

Log Authentication Events

RUBY
# app/controllers/sessions_controller.rb
def create
  if user&.authenticate(params[:password])
    Rails.logger.info("Login successful: #{user.email}")
  else
    Rails.logger.warn("Login failed: #{params[:email]}")
  end
end

Monitor Failed Login Attempts

RUBY
class UsersController < ApplicationController
  before_action :check_failed_attempts

  private

  def check_failed_attempts
    if RateLimiter.exceeded?(request.ip, "login")
      render json: { error: "Too many login attempts" }, status: 429
    end
  end
end

Use Sentry for Error Tracking

RUBY
# Gemfile
gem "sentry-ruby", "~> 5.0"
gem "sentry-rails", "~> 5.0"
RUBY
# config/initializers/sentry.rb
Sentry.init do |config|
  config.dsn = ENV["SENTRY_DSN"]
  config.environment = Rails.env
end

The Security Checklist

Authentication

  • [ ] Use Devise or Rails built-in auth
  • [ ] Rate limit login attempts
  • [ ] Regenerate session on login
  • [ ] Set session timeout

Authorization

  • [ ] Use Pundit or CanCanCan
  • [ ] Authorize on server side
  • [ ] Check permissions in views

Data Security

  • [ ] Use parameterized queries
  • [ ] Use strong parameters
  • [ ] Sanitize user input

Headers

  • [ ] Add HSTS header
  • [ ] Set X-Frame-Options to DENY
  • [ ] Set X-Content-Type-Options
  • [ ] Configure Content-Security-Policy

Monitoring

  • [ ] Set up error tracking
  • [ ] Log suspicious activity
  • [ ] Run security scans

Summary

Tool Purpose
Devise/Rails Auth Authentication
Pundit/CanCanCan Authorization
Strong Parameters Mass assignment protection
Secure Headers Security headers
Brakeman Security scanning
Sentry Error monitoring

Quick Security Commands

BASH
# Run security scans
brakeman
bundle audit

# Update vulnerable gems
bundle update

# Check security headers
curl -I https://yourapp.com

Security is not a one-time task. It's an ongoing process.

Review your security regularly. Test your assumptions. Keep learning.

Your users trust you with their data. Honor that trust.

CODE
Derrick Orare

Derrick Orare

Senior Software Engineer with 11+ years of experience building scalable backend systems, APIs and fintech platforms

Discussion

0 Comments

Questions, feedback or corrections are welcome.

💬

No discussion yet.

Be the first to share your thoughts.

Leave a comment