The Security Work That's Actually on You
Rails handles framework-level security defaults — but authorization, rate limiting, CSP, session hardening, and encrypted attributes are decisions only you can make.
The previous post covered everything Rails handles by default — CSRF, SQL injection, XSS, encrypted sessions, security headers, strong parameters. That’s a solid foundation. But it’s a foundation, not a finished building.
There’s a layer of security work that no framework can do for you, because it depends on decisions only you can make: who your users are, what they’re allowed to do, and how aggressively you want to protect your endpoints. This post is about that layer.
It applies whether you wrote the app yourself or it came mostly from Claude. The AI didn’t make these decisions — it can’t. But you can make most of them in an afternoon, and some generators will do the scaffolding for you.
Authorization is Not Authentication
Rails 8.0 ships a full authentication generator. It gives you login, logout, password reset, and a current_user method in your controllers. What it doesn’t give you is any answer to the question: what is this logged-in user allowed to do?
That’s the distinction between authentication (who are you?) and authorization (what can you do?). Rails handles the first. The second is on you.
Without explicit authorization checks, your app will happily let any authenticated user access any other user’s data — as long as they can guess the ID. This is called an Insecure Direct Object Reference, and it’s one of the most common vulnerabilities in web applications built by people who thought “logged in = authorized.”
Layer one: always scope queries to the current user.
# Wrong — fetches any project by ID, regardless of who owns it
def show
@project = Project.find(params[:id])
end
# Right — fetches only projects belonging to the current user's account
def show
@project = Current.account.projects.find(params[:id])
end
If a user tries to access a project that doesn’t belong to their account, find raises ActiveRecord::RecordNotFound and Rails returns a 404. No special logic required. The scope does the work.
Layer two: put authorization logic on the model, not the controller.
The rails-simplifier approach — which follows 37signals patterns and the One Person Framework philosophy — pushes authorization predicates into model methods rather than controller before_action callbacks. Thin controllers, rich models. The model knows what it is and what can be done to it; the controller just asks.
# Fragile — authorization logic scattered across controllers
class DocumentsController < ApplicationController
before_action :check_editor_role, only: [:edit, :update]
private
def check_editor_role
unless Current.user.role == "admin" || Current.user.role == "editor"
redirect_to documents_path, alert: t("flash.general.forbidden")
end
end
end
# Better — the model answers the question, the controller just enforces it
class User < ApplicationRecord
enum :role, { member: "member", editor: "editor", admin: "admin" }
def can_edit_documents?
editor? || admin?
end
def can_manage_users?
admin?
end
end
class DocumentsController < ApplicationController
before_action :require_editor, only: [:edit, :update]
private
def require_editor
redirect_to documents_path, alert: t("flash.general.forbidden") unless Current.user.can_edit_documents?
end
end
The controller no longer knows what roles exist or how they combine. That logic lives in one place — the User model — and can be tested in isolation with a unit test:
test "editor can edit documents" do
assert users(:editor).can_edit_documents?
assert users(:admin).can_edit_documents?
refute users(:member).can_edit_documents?
end
This is what the Maquina Registration generator scaffolds out of the box: a role enum on User, a Current.user.admin? predicate, and the pattern for adding more as your app grows. The controller just calls a named predicate — it doesn’t reason about roles directly.
Layer three: a policy library when complexity demands it.
For most MVPs and early-stage apps, model predicate methods are enough. When authorization logic becomes genuinely complex — multiple resource types with different rules, conditional access based on ownership plus role plus subscription tier — that’s when Pundit or Action Policy earn their place. Both give you policy objects: one class per model, one method per action, all in one directory, all unit-testable.
# app/policies/document_policy.rb (Pundit)
class DocumentPolicy < ApplicationPolicy
def update?
record.account == user.account && user.can_edit_documents?
end
def destroy?
user.admin?
end
end
The path is: scoped queries first, then model predicates, then a policy library if you genuinely need it. Don’t reach for the library before you need it. The model predicate approach covers a lot of real applications without any extra dependency.
Rate Limit Your Attack-Prone Endpoints
Rails 8.0 added a rate_limit macro to controllers. It’s backed by your cache store — Solid Cache, Redis, whatever you’re using — and requires no external gem.
The most important places to put it:
# Login — 5 attempts per IP per 3 minutes stops credential stuffing cold
class SessionsController < ApplicationController
rate_limit to: 5, within: 3.minutes, only: :create,
by: -> { request.remote_ip },
with: -> { redirect_to new_session_url, alert: "Too many attempts. Try again shortly." }
end
# Password reset — prevents email flooding
class PasswordResetsController < ApplicationController
rate_limit to: 3, within: 15.minutes, only: :create,
by: -> { request.remote_ip }
end
# Registration — stops mass account creation from a single IP
class RegistrationsController < ApplicationController
rate_limit to: 3, within: 1.hour, only: :create,
by: -> { request.remote_ip }
end
The by: proc is important. Defaulting to request.remote_ip throttles by IP, which is fine but easy to bypass with a proxy pool. For sensitive endpoints like login, also throttle by the submitted email address — this limits attacks against a specific account regardless of how many IPs the attacker rotates through:
throttle("logins/email", limit: 10, period: 15.minutes) do |req|
if req.path == "/session" && req.post?
req.params.dig("session", "email_address").to_s.downcase.strip.presence
end
end
That example is Rack::Attack syntax, which brings us to the next point.
Add Rack::Attack for Infrastructure-Level Protection
rate_limit protects your controller actions. Rack::Attack sits at the Rack middleware layer — before your router, before your controllers, before any database connections. It’s where you block known bad actors, scanners, and bot traffic before they consume any resources. I wrote about this gem back in 2020 — see Proteger tu aplicación de ataques en Internet for the long-form Spanish version — and the patterns have only gotten more relevant since.
Install it, add it to the middleware stack, and configure a production-ready initializer:
# Gemfile
gem "rack-attack"
# config/application.rb
config.middleware.use Rack::Attack
# config/initializers/rack_attack.rb
class Rack::Attack
Rack::Attack.cache.store = Rails.cache
# --- Safelists ---
# Never throttle your health check endpoint
safelist("allow /up") { |req| req.path == "/up" }
# Don't throttle localhost in development
safelist("allow localhost") { |req| Rails.env.local? && ["127.0.0.1", "::1"].include?(req.ip) }
# --- Blocklists ---
# Block IPs you've explicitly flagged for abuse
# To block: Rails.cache.write("rack_attack:blocklist:1.2.3.4", true, expires_in: 1.week)
blocklist("block flagged IPs") { |req| Rails.cache.read("rack_attack:blocklist:#{req.ip}") }
# Block scanner user agents — these are never legitimate traffic
MALICIOUS_AGENTS = /\b(sqlmap|nikto|nmap|masscan|acunetix|dirbuster|zgrab)\b/i
blocklist("block scanners") { |req| req.user_agent&.match?(MALICIOUS_AGENTS) }
# Block probes for paths that don't exist in Rails apps
SCANNER_PATHS = %r{
\.(php|asp|aspx|env|git|svn|htaccess|DS_Store) |
/wp-admin | /wp-login | /phpmyadmin | /actuator
}xi
blocklist("block scanner paths") { |req| req.path.match?(SCANNER_PATHS) }
# --- Throttles ---
# Global flood protection — 500 requests per IP per 5 minutes
throttle("req/ip", limit: 500, period: 5.minutes) do |req|
req.ip unless req.path.start_with?("/assets")
end
# Login throttle by IP (short burst window)
throttle("logins/ip/burst", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/session" && req.post?
end
# Login throttle by email (prevents account-targeted attacks)
throttle("logins/email", limit: 10, period: 15.minutes) do |req|
if req.path == "/session" && req.post?
req.params.dig("session", "email_address").to_s.downcase.strip.presence
end
end
# --- Custom 429 response ---
self.throttled_responder = lambda do |req|
match_data = req.env["rack.attack.match_data"]
retry_in = match_data[:period] - (match_data[:epoch_time] % match_data[:period])
[429, { "Content-Type" => "application/json", "Retry-After" => retry_in.to_s },
[{ error: "Rate limit exceeded", retry_after: retry_in }.to_json]]
end
# --- Log everything Rack::Attack blocks or throttles ---
ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, req|
event = req.env["rack.attack.match_type"]
Rails.logger.warn("[Rack::Attack] #{event.upcase} matched=#{req.env['rack.attack.matched']} ip=#{req.ip} path=#{req.path}")
end
end
The blocklist for scanner paths is worth highlighting. PHP probes, WordPress login attempts, .env file requests — these hit every public Rails app constantly. They never get a useful response, but they do consume a thread and a log line. Blocking them at the Rack level costs almost nothing.
One important caveat: if your app runs behind a load balancer or reverse proxy, req.ip will be the proxy’s IP unless you configure trusted proxies in Rails. Without this, Rack::Attack will rate-limit your load balancer instead of your users:
# config/application.rb
config.action_dispatch.trusted_proxies =
ActionDispatch::RemoteIp::TRUSTED_PROXIES + [IPAddr.new("your.proxy.ip")]
Tighten Your Content Security Policy
Rails generates a CSP initializer in every new app, but the default is permissive enough to not break anything. That’s intentional — you need to tighten it for your specific app.
Start in report-only mode so you can see what would be blocked before you start blocking it:
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :none
policy.script_src :self
policy.style_src :self
policy.img_src :self, :data, "https://your-cdn.example.com"
policy.font_src :self
policy.connect_src :self
policy.frame_ancestors :none
policy.base_uri :self
policy.form_action :self
policy.report_uri "/csp-violations"
end
# Start with report-only — watch your logs before flipping this to false
Rails.application.config.content_security_policy_report_only = true
Use nonces for any inline scripts instead of unsafe-inline:
<%# In your layout %>
<%= javascript_tag nonce: true do %>
// Your inline JS here
<% end %>
policy.script_src :self, :nonce
Harden Your Session Cookie
The Rails default is SameSite=Lax. That’s reasonable but not the tightest option. If your app doesn’t need to accept form submissions from other sites — and most apps don’t — set it to :strict and add an explicit expiry:
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
key: "_myapp_session",
secure: Rails.env.production?,
httponly: true,
same_site: :strict,
expire_after: 2.hours
Encrypt PII at the Column Level
If your app stores phone numbers, government IDs, financial account numbers, or any data that’s sensitive enough to cause real harm in a breach, use Active Record Encryption to encrypt it at the column level. If your database gets dumped, that data is unreadable without the encryption keys.
class User < ApplicationRecord
encrypts :phone_number, :tax_id
encrypts :email_address, deterministic: true # Allows WHERE queries on encrypted column
end
The keys live in your credentials file:
# config/application.rb
config.active_record.encryption.primary_key = credentials.encryption_primary_key
config.active_record.encryption.deterministic_key = credentials.encryption_deterministic_key
config.active_record.encryption.key_derivation_salt = credentials.encryption_key_derivation_salt
Generate the keys with: bin/rails db:encryption:init
Run Brakeman and Bundler Audit in CI
Brakeman is a static analysis tool that understands Rails idioms. It catches things like unescaped output, mass assignment risks, SQL injection from string interpolation, unsafe deserialization, and more. Bundler Audit cross-references your Gemfile.lock against known CVEs — dependencies go stale, security issues get discovered in gems you haven’t thought about in months.
The good news: Rails 8 already sets this up for you. Every new Rails 8 app ships with a bin/ci script and a .github/workflows/ci.yml that run both tools as part of the standard CI pipeline. You don’t need to add anything.
# bin/ci — generated by Rails 8, already includes:
bundle exec brakeman --no-pager -q
bundle exec bundler-audit check --update
If you’re on an existing app that predates Rails 8 and doesn’t have this yet, add both gems and wire up the workflow manually:
# Gemfile
group :development do
gem "brakeman", require: false
gem "bundler-audit", require: false
end
# .github/workflows/ci.yml — security steps to add
- name: Brakeman
run: bundle exec brakeman --no-pager -q
- name: Bundler Audit
run: bundle exec bundler-audit check --update
This is one of the cleaner examples of Rails gradually absorbing what used to be manual setup. The framework doesn’t just give you the tools — it runs them for you from day one.
If You’re Starting from Zero: Maquina Generators
If you’re spinning up a new Rails app and you want most of the above wired in from the start, the Maquina Generators gem does the bootstrapping. It’s development-only — generates code into your app and leaves no runtime dependency.
rails new myapp --css tailwind
bundle add maquina-generators --group development
rails generate maquina:app --auth clave
bin/rails db:migrate
bin/dev
The maquina:app generator runs several sub-generators, including maquina:rack_attack, which scaffolds the Rack::Attack initializer with scanner blocklists and login throttles already configured.
For authentication, you have two choices:
--auth registration generates password-based authentication on top of Rails 8’s built-in auth generator. It adds an Account model for multi-tenancy, attaches users to accounts, adds a role enum (admin/member), rate-limits the registration controller, and scopes everything to Current.account from the start.
--auth clave generates passwordless authentication. (If you do go the password route and want a second factor, I covered TOTP in Autenticación Two-Factor (2FA) — same principles still apply.) Users enter their email, receive a 6-digit code, enter the code, done. No passwords to store, no password reset flow to build, no credential stuffing surface to worry about. Codes expire in 15 minutes. Resends are rate-limited to one attempt per 15-minute window. The email field blocks + characters to prevent alias attacks. The whole thing is multi-tenant by default, and it comes with an AuthenticationCleanupJob that purges expired sessions and codes daily.
Neither of these is a library you’re trusting as a black box. It’s generated code that lives in your app. You can read it, modify it, and own it.
The Honest Summary
The question from the audience was whether you can trust AI-generated Rails code from a security standpoint. The answer has two parts.
The first part — covered in the previous post — is that Rails handles a lot of it at the framework level, and an AI tool writing Rails code will produce idiomatic, safe patterns because that’s what the training data is full of.
The second part is this post. Authorization, rate limiting, CSP configuration, session hardening, encrypted attributes — none of that gets decided for you automatically. But it’s also not complicated. Most of it is configuration. Some of it is a one-liner in a controller. The CI scanning is already there if you’re on Rails 8. All of it is learnable in an afternoon.
Vibe-coding doesn’t change any of this. The security work that’s on you has always been on you. The difference is that you now have tools that can help you write the implementation faster — which means you have more time to think about the decisions.