Rails Security Patterns
1. Passwordless Authentication (Magic Links)
Password-based auth brings weak passwords, reuse, and credential stuffing. Magic links instead: user enters email, receives a short-lived code, enters it to authenticate.
# app/models/magic_link.rb
class MagicLink < ApplicationRecord
CODE_LENGTH = 6
EXPIRATION_TIME = 15.minutes
belongs_to :identity
enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
before_validation :generate_code, on: :create
before_validation :set_expiration, on: :create
validates :code, uniqueness: true, presence: true
class << self
def consume(code)
active.find_by(code: Code.sanitize(code))&.consume
end
def cleanup
stale.delete_all
end
end
def consume
destroy # codes are single-use
self
end
private
def generate_code
self.code ||= loop do
candidate = Code.generate(CODE_LENGTH)
break candidate unless self.class.exists?(code: candidate)
end
end
def set_expiration
self.expires_at ||= EXPIRATION_TIME.from_now
end
end
# app/models/identity.rb
def send_magic_link(**attributes)
magic_links.create!(attributes).tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
# app/controllers/sessions/magic_links_controller.rb
def create
if magic_link = MagicLink.consume(code)
authenticate magic_link
else
invalid_code
end
end
private
def authenticate(magic_link)
if ActiveSupport::SecurityUtils.secure_compare(
email_address_pending_authentication || "",
magic_link.identity.email_address
)
sign_in magic_link
else
email_address_mismatch
end
end
Key Points:
secure_compareprevents timing attacks on the email comparison.- A
Code.sanitizestep handles common user typos (O→0, I/L→1).
2. Session Management
Store session records in the database, reference them via signed cookies — sessions become trackable, revocable, and auditable.
# app/controllers/concerns/authentication.rb
def start_new_session_for(identity)
identity.sessions.create!(
user_agent: request.user_agent,
ip_address: request.remote_ip
).tap do |session|
set_current_session session
end
end
def set_current_session(session)
Current.session = session
cookies.signed.permanent[:session_token] = {
value: session.signed_id, # tamper-proof token
httponly: true, # no JavaScript access
same_site: :lax # CSRF protection
}
end
def resume_session
if session = find_session_by_cookie
set_current_session session
end
end
def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end
def terminate_session
Current.session.destroy
cookies.delete(:session_token)
end
3. Authorization Concern (Role-Based Access)
Define role checks as concern methods, apply them via before_action filters.
# app/models/user/role.rb
module User::Role
extend ActiveSupport::Concern
included do
enum :role, %i[ owner admin member system ].index_by(&:itself), scopes: false
scope :owner, -> { where(active: true, role: :owner) }
scope :admin, -> { where(active: true, role: %i[ owner admin ]) }
scope :active, -> { where(active: true, role: %i[ owner admin member ]) }
def admin?
super || owner? # owners are also admins
end
end
def can_administer?(other)
admin? && !other.owner? && other != self
end
end
# app/controllers/concerns/authorization.rb
module Authorization
extend ActiveSupport::Concern
included do
before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? }
end
class_methods do
def allow_unauthorized_access(**options)
skip_before_action :ensure_can_access_account, **options
end
end
private
def ensure_admin
head :forbidden unless Current.user.admin?
end
def ensure_staff
head :forbidden unless Current.identity.staff?
end
def ensure_can_access_account
if Current.user.blank? || !Current.user.active?
respond_to do |format|
format.html { redirect_to session_menu_path(script_name: nil) }
format.json { head :forbidden }
end
end
end
end
# Usage
class WebhooksController < ApplicationController
before_action :ensure_admin
end
class Account::SettingsController < ApplicationController
before_action :ensure_admin, only: :update
end
When role gates aren't enough: Pundit
The concern above is right for coarse gates (admin?, staff?). When authorization becomes per-resource — "can this user edit that post?" — routing every rule through before_action filters turns controllers into a tangle. Reach for Pundit: one plain-Ruby policy object per resource, denying by default, trivial to unit-test.
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record) = (@user, @record = user, record)
def update? = false # deny by default; subclasses open up
class Scope
def initialize(user, scope) = (@user, @scope = user, scope)
def resolve = raise NotImplementedError
end
end
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def update? = record.account == user.account && (user.admin? || record.author == user)
class Scope < Scope
def resolve = @scope.where(account: @user.account)
end
end
class PostsController < ApplicationController
include Pundit::Authorization
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
def index = (@posts = policy_scope(Post))
def update
@post = Post.find(params[:id])
authorize @post # raises Pundit::NotAuthorizedError unless update?
@post.update!(permitted_attributes(@post))
end
end
Choosing an authorization approach:
| Need | Reach for |
|---|---|
Coarse role gates (admin?, staff?) |
The concern + before_action above — no gem |
| Per-resource rules across many models | Pundit — plain policy objects, easy to test, scales with the model count |
| (avoid as the default) | CanCanCan centralises every rule in one Ability DSL; fine while small, but the single file becomes a merge bottleneck and is harder to test as rules grow. Prefer Pundit's per-resource objects. |
policy_scope is also the cleanest place to enforce tenant isolation on collections — fold Current.account into the policy Scope so index can't leak across tenants ([[rails-multi-tenancy]], Pattern 6 below).
Authentication, by the same logic: the built-in Rails 8 flow (Patterns 1–2 — magic links, DB sessions, has_secure_password) is the default — you own the code and add no dependency. Reach for Devise only when you specifically want its module ecosystem (confirmable, lockable, omniauth) and don't want to build it, not as a reflex.
4. Bearer Token API Access
Access tokens with read/write permission levels; API requests authenticate via the Authorization header as a fallback to the session.
# app/models/identity/access_token.rb
class Identity::AccessToken < ApplicationRecord
belongs_to :identity
has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read
def allows?(method)
method.in?(%w[ GET HEAD ]) || write?
end
end
# app/controllers/concerns/authentication.rb
def require_authentication
resume_session || authenticate_by_bearer_token || request_authentication
end
def authenticate_by_bearer_token
if request.authorization.to_s.include?("Bearer")
authenticate_or_request_with_http_token do |token|
if identity = Identity.find_by_permissable_access_token(token, method: request.method)
Current.identity = identity
end
end
end
end
# Show the token only once: a 10-second expiring signed id for the redirect
class My::AccessTokensController < ApplicationController
def create
access_token = Current.identity.access_tokens.create!(access_token_params)
expiring_id = Rails.application.message_verifier(:access_tokens)
.generate(access_token.id, expires_in: 10.seconds)
redirect_to my_access_token_path(expiring_id)
end
end
5. Rate Limiting
Rails 8's built-in rate_limit on brute-forceable endpoints, with a handler for exceeded limits:
class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
private
def rate_limit_exceeded
respond_to do |format|
format.html { redirect_to new_session_path, alert: "Try again later." }
format.json { render json: { message: "Try again later." }, status: :too_many_requests }
end
end
end
# Inline handler variant
class SignupsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to new_signup_path, alert: "Try again later." }
end
Scale limits to the endpoint: login/signup 10 per 3 minutes, magic-link verification 10 per 15 minutes, email changes 5 per hour.
6. Account Scoping (Multi-Tenant Security)
Tenant isolation is the highest-stakes authorization boundary. The mechanism — account-slug middleware, Current attributes, require_account / disallow_account_scope — lives in [[rails-multi-tenancy]]. The security properties to preserve:
- Account is extracted from the URL path by middleware, never from user input.
Current.accountis scoped to the request lifecycle;Current.useris resolved from identity and account, so an identity can never act in an account it has no user in.- Every tenant-scoped query goes through
Current.account/ the user's accessible scopes ([[rails-controllers]] Pattern 4) —Model.find(params[:id])is a cross-tenant leak. - Routes without account scope must explicitly opt out (
disallow_account_scope) and redirect tenanted requests away.
7. CSRF Protection (Request Forgery Protection)
Modern browsers send the Sec-Fetch-Site header; verifying it replaces token plumbing that fights page caching (csrf_meta_tags in cached layouts, token-refresh JavaScript, "token dispenser" endpoints).
On Rails 8.2+, use the built-in strategy:
# config/application.rb
config.action_controller.forgery_protection_strategy = :header_or_legacy_token
This verifies Sec-Fetch-Site and falls back to the classic token for browsers that don't send it. Browser support floor: Chrome 76+ (2019), Edge 79+ (2020), Firefox 90+ (2021), Safari 16.4+ (2023).
On older Rails, implement it yourself:
# app/controllers/concerns/request_forgery_protection.rb
module RequestForgeryProtection
extend ActiveSupport::Concern
included do
after_action :append_sec_fetch_site_to_vary_header
end
private
def append_sec_fetch_site_to_vary_header
vary_header = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:blank?)
response.headers["Vary"] = (vary_header + [ "Sec-Fetch-Site" ]).join(",")
end
def verified_request?
request.get? || request.head? || !protect_against_forgery? ||
(valid_request_origin? && safe_fetch_site?)
end
SAFE_FETCH_SITES = %w[ same-origin same-site ]
def safe_fetch_site?
SAFE_FETCH_SITES.include?(sec_fetch_site_value) ||
(sec_fetch_site_value.nil? && api_request?)
end
def api_request?
request.format.json? # JSON without the header uses bearer token auth
end
def sec_fetch_site_value
request.headers["Sec-Fetch-Site"].to_s.downcase.presence
end
end
Skip forgery protection only for truly public endpoints (e.g. the PWA service worker controller).
8. Content Security Policy (CSP)
Rails ships ActionDispatch::ContentSecurityPolicy (Rails 5.2+). A properly configured CSP is one of the strongest XSS mitigations: even if an attacker injects a <script> into a page, the browser refuses to execute it unless it carries the per-request nonce.
Generate the initializer:
bin/rails generate content_security_policy
Initializer with nonce support:
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
policy.font_src :self
policy.img_src :self, :data
policy.object_src :none
policy.script_src :self, :nonce # nonce covers Importmap + inline scripts
policy.style_src :self, :nonce
policy.report_uri "/csp-violation-reports" # collect violations during rollout
end
# Per-request nonce; Rails injects it automatically into helper-generated tags
Rails.application.config.content_security_policy_nonce_generator = ->(_request) {
SecureRandom.base64(16)
}
# Which directives receive the auto-injected nonce
Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
Rails automatically adds nonce="…" to tags from javascript_include_tag, stylesheet_link_tag, and javascript_importmap_tags. For any hand-written inline blocks:
<script nonce="<%= content_security_policy_nonce %>">
<%# inline code that needs the nonce %>
</script>
Roll out with report-only first:
Enforcing CSP on an app that hasn't been through report-only is the fastest way to silently break it in production. Start here:
# config/environments/production.rb
Rails.application.config.content_security_policy_report_only = true
A minimal endpoint to collect violations during rollout:
# config/routes.rb
post "/csp-violation-reports", to: "csp_reports#create"
# app/controllers/csp_reports_controller.rb
class CspReportsController < ApplicationController
skip_before_action :verify_authenticity_token
allow_unauthenticated_access
def create
Rails.logger.warn "CSP violation: #{request.body.read}"
head :ok
end
end
Each violation log line names the blocked source. Add legitimate sources to the policy until the logs are clean, then remove content_security_policy_report_only to enforce.
Common mistakes:
| Mistake | Why it's bad | Fix |
|---|---|---|
policy.script_src :unsafe_inline |
Allows all inline scripts — XSS protection is gone | Use nonces instead |
policy.script_src "*" |
Allows scripts from any external origin | List only the domains you actually load scripts from |
Omitting object_src :none |
Old-style plugin injection still possible | Always set it to :none |
| Skipping report-only | Breaks the app silently on first deploy | Always start in report-only mode |
| Missing nonce directives | Importmap / inline scripts fail in production | Set content_security_policy_nonce_directives |
Security Checklist
When implementing new features, verify:
- Authentication required? Use
require_authentication(default) or explicitlyallow_unauthenticated_access - Admin-only? Add
before_action :ensure_admin - Staff-only? Add
before_action :ensure_staff - Rate limit sensitive actions? Add
rate_limit to: N, within: X.minutes - Account-scoped? Ensure data queries include
Current.account - Timing-safe comparisons? Use
ActiveSupport::SecurityUtils.secure_comparefor secrets - Signed/encrypted cookies? Use
cookies.signedorcookies.encrypted - Signed IDs for URLs? Use
model.signed_idfor unguessable references - Content Security Policy? CSP initializer present with nonce generator; rolled out via
_report_onlybefore enforcing