>_rails inside
~/rails-inside $ cat index.html
HomePostsAbout
// rails inside //// est. 2026 //// vol.01

Senior Rails notes, with code.

An editorial journal for senior Ruby on Rails developers. Three long-form pieces on the gems that matter, refactoring legacy code without rewrites, and the security defaults you probably turned off.

// latest////3 posts////updated Apr 21, 2026////~21 min total
// 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.

Read on the article page

// 02  TOOLING  ////  Apr 2, 2026  ////  6 min

The Best Ruby on Rails Plugins for Modern Web Development

A curated look at the gems and plugins that belong in every modern Rails project, from background jobs to full-text search.

The Rails ecosystem has always depended on well-designed gems to fill the gaps between framework conventions and real-world application requirements. In 2026, the quality bar for these plugins is higher than ever. The best ones integrate cleanly with Rails idioms, ship with generators that scaffold sensible defaults, and maintain test coverage that lets you upgrade without fear.

This guide covers the plugins that senior Rails developers reach for on greenfield and brownfield projects alike, organized by problem domain rather than popularity metrics.

Authentication and Authorization

Devise remains the standard entry point for authentication, but recent versions have been meaningfully overhauled to support Turbo-compatible form flows. If you are starting a new project, start with Devise and the devise-two-factor extension.

# Gemfile
gem "devise", "~> 4.9"
gem "devise-two-factor"

For finer-grained access control, Pundit is the cleaner choice over CanCanCan for most modern codebases. Pundit's policy objects are plain Ruby classes, which makes them straightforward to test in isolation:

class ArticlePolicy < ApplicationPolicy
  def update?
    user.admin? || record.author == user
  end
end

Pundit's explicit policy-per-model approach forces developers to make authorization decisions visible rather than burying them in before-action callbacks.

Background Processing

Sidekiq is the production standard. Its Pro and Enterprise tiers add batching and unique job enforcement that are necessary at any meaningful scale. For simpler projects where Redis is not in the stack, Solid Queue, shipped as a first-party Rails plugin by the core team, runs on your existing database.

# config/application.rb
config.active_job.queue_adapter = :solid_queue

Solid Queue's SolidQueue::Job model surfaces in your existing database, which simplifies operational monitoring without adding another data store.

Full-Text Search

PgSearch is the pragmatic choice for applications already running PostgreSQL. It wraps tsvector and tsquery natively without requiring Elasticsearch:

include PgSearch::Model

pg_search_scope :search_by_content,
  against: [:title, :body],
  using: { tsearch: { prefix: true } }

For applications that outgrow PostgreSQL search, high-volume ecommerce catalogs, for example, Meilisearch has a well-maintained Rails integration that is significantly easier to operate than Elasticsearch clusters.

File Uploads and Storage

Active Storage handles the majority of file upload scenarios out of the box, but the image_processing gem backed by libvips is a significant improvement over MiniMagick for variant generation. Add it to any project doing image transforms:

# Gemfile
gem "image_processing", "~> 1.2"

libvips processes images with roughly 60% lower memory consumption than ImageMagick and completes most operations faster. The API difference from the application side is zero, Active Storage routes to libvips automatically when image_processing is present.

API Serialization

The Blueprinter gem offers a straightforward DSL for defining JSON serializers without the configuration overhead of jsonapi-serializer. For projects that need strict JSON:API compliance, jsonapi-serializer is the correct pick. For everything else, Blueprinter keeps the serialization layer readable:

class UserBlueprint < Blueprinter::Base
  identifier :id
  fields :email, :created_at

  view :with_profile do
    association :profile, blueprint: ProfileBlueprint
  end
end

Developer Experience Tools

Three gems belong in every development group: bullet for catching N+1 queries before they reach production, rack-mini-profiler for wall-clock profiling in the browser, and annotate for stamping schema comments at the top of model files.

group :development do
  gem "bullet"
  gem "rack-mini-profiler"
  gem "annotate"
end

Bullet's Slack integration notifies your team channel when N+1 queries appear in development, which makes the feedback loop tight enough that developers fix them immediately rather than deferring until a performance review.

Pagination

Pagy has replaced Kaminari as the preferred pagination gem for performance-sensitive applications. It allocates far fewer objects per request and supports Turbo Streams natively. The API is familiar enough that migration from Kaminari is usually an afternoon of work:

include Pagy::Backend
# in controller
@pagy, @articles = pagy(Article.published)

Choosing and Evaluating Gems

Before adding any gem, check three things: when it was last released, whether it has a funded maintainer or organizational backing, and whether it ships tests you can run locally. A gem untouched for three years and maintained by a single individual represents operational risk in a production codebase.

The Rails ecosystem rewards specificity. A gem that does one thing and integrates cleanly with Rails conventions is worth more than a Swiss Army knife that owns its own opinions about every layer of your stack.

For further reading on gem evaluation patterns and the gems mentioned here, see the Refactoring Legacy Rails Applications and Essential Security Best Practices for Rails Developers guides on this site.

Read on the article page

// 03  PERFORMANCE  ////  Mar 11, 2026  ////  7 min

How to Refactor Legacy Rails Applications for Performance

A systematic approach to refactoring legacy Rails codebases: identifying bottlenecks, untangling fat models, and improving query performance without rewriting everything.

Most Rails developers eventually inherit a codebase that has accumulated years of shortcuts. Models with 800-line files, controllers handling business logic, missing database indexes, and N+1 queries in every action. The question is never whether to refactor, it is how to do it without shipping regressions and without losing months to a ground-up rewrite that never ships.

This guide covers a disciplined, measurable approach to Rails refactoring with a focus on performance payoffs that are visible to end users.

Start with Measurement, Not Opinion

Before touching a line of code, establish a baseline. Opinions about what is slow are almost always wrong. Add rack-mini-profiler to the development environment and enable it against a production database dump:

group :development do
  gem "rack-mini-profiler"
  gem "flamegraph"
  gem "stackprof"
end

In production, Skylight and Scout APM both provide per-endpoint timing with minimal overhead. Export the slowest 20 endpoints and commit that list to a file in the repository. Every refactoring session should move at least one endpoint off the list.

Database Indexes: The Highest Return Work

Missing indexes are the most common performance problem in legacy Rails applications and the easiest to fix. The lol_dba gem scans your schema and reports missing indexes based on foreign keys and common query patterns:

bundle exec rake db:find_indexes

Add the indexes in a migration with algorithm: :concurrently to avoid locking production tables:

class AddIndexToOrdersUserId < ActiveRecord::Migration[7.2]
  disable_ddl_transaction!

  def change
    add_index :orders, :user_id, algorithm: :concurrently
  end
end

A missing index on a foreign key in a table with a million rows typically means a full sequential scan on every association load. Fixing it is a one-line migration that takes seconds.

Eliminating N+1 Queries

N+1 queries are the second most common source of latency. The bullet gem surfaces them in development logs and can be configured to raise exceptions:

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.raise = true
  Bullet.add_footer = true
end

The fix is almost always includes or preload:

# Before, fires one query per post
@posts = Post.published.limit(20)

# After, two queries total
@posts = Post.published.includes(:author, :tags).limit(20)

Be precise with includes. Loading every association on a model to avoid N+1s creates wide result sets that slow down everything else. Profile before and after each change.

Decomposing Fat Models

The Rails convention of "fat models, skinny controllers" has a failure mode: models that handle persistence, business logic, third-party API calls, email delivery, and background job scheduling. A model file over 300 lines almost certainly has this problem.

The correct decomposition depends on the type of logic:

  • Business rules that operate on a single model go into a Service Object.
  • Multi-step workflows that touch several models go into a Command Object or a process class.
  • Reusable query logic goes into a Query Object.
# app/services/order_fulfillment_service.rb
class OrderFulfillmentService
  def initialize(order)
    @order = order
  end

  def call
    ActiveRecord::Base.transaction do
      @order.reserve_inventory!
      @order.charge_payment!
      @order.update!(status: :confirmed)
      OrderMailer.confirmation(@order).deliver_later
    end
  end
end

The model retains reserve_inventory! and charge_payment! as focused state-transition methods. The service object owns the orchestration. This split makes each class independently testable.

Query Objects for Complex Scopes

Named scopes that chain three or more conditions, or that join multiple tables, belong in a dedicated query object rather than a model scope:

# app/queries/active_subscriber_query.rb
class ActiveSubscriberQuery
  def initialize(relation = User.all)
    @relation = relation
  end

  def call
    @relation
      .joins(:subscription)
      .where(subscriptions: { status: :active, plan: :paid })
      .where("subscriptions.expires_at > ?", Time.current)
      .order("users.created_at DESC")
  end
end

# Usage
ActiveSubscriberQuery.new.call
ActiveSubscriberQuery.new(User.where(country: "US")).call

This pattern makes query logic reusable and eliminates the User.active_paid_subscribers_in_good_standing scope chain that is impossible to test in isolation.

Caching Without Over-Engineering

Fragment caching with Russian Doll cache keys is the right first caching layer for most Rails applications. Enable it in production and ensure cache keys include updated_at:

# In a view partial
<% cache @article do %>
  <%= render partial: "article_body", locals: { article: @article } %>
<% end %>

Rails automatically invalidates the cache when @article.updated_at changes. The key mistake is caching database queries rather than rendered fragments. Query caching produces stale data that is hard to reason about; fragment caching scopes invalidation to the record's own lifecycle.

Upgrading Rails Itself

Running a legacy Rails version is itself a performance liability. Rails 7.1 and later ship Solid Cache and Solid Queue as first-party options, and the query planner improvements in recent versions of Active Record reduce object allocation significantly.

The official upgrade guide covers each version pair. The practical approach is to use the next_rails gem to run your test suite against the next Rails version before changing the Gemfile:

bundle exec next rake test

Fix deprecation warnings one Rails version at a time. Attempting a multi-version jump without intermediate stops creates a debugging problem that consumes the productivity gains the upgrade was supposed to deliver.

Tracking Progress

Every refactoring effort needs an exit condition. Before starting, document the three metrics that matter most: p95 response time for the five slowest endpoints, the number of N+1 queries flagged by Bullet in a full test run, and the number of model files over 300 lines. Review those numbers weekly. When all three are at target, the refactoring phase is complete.

For tooling recommendations that support these patterns, see The Best Ruby on Rails Plugins for Modern Web Development and the security hardening guidance in Essential Security Best Practices for Rails Developers.

Read on the article page