Rails security
Agent skills for working with Ruby on Rails 8+
npx -y skills add mickzijdel/rails-toolkit --skill rails-securityAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when implementing authentication, authorization, or security features in Rails
SKILL.md
15.7 KB, ~3.8k tokens by cl100k_base, as published. Nobody here has run it
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
Gives 1 of the 12 instructions most security skills give in ~3.8k tokens
Counted across 648 of the 828 authors here whose files we hold, read 2026-08-06
- parameterize all database queriesin 67 of 648, across 49 files
- hash passwords using bcrypt scrypt or argon2in 48 of 648, across 35 files
- apply rate limiting to authentication endpointshere, and in 48 of 648, across 24 files
- Configure security headersin 35 of 648, across 18 files
- validate all inputsin 32 of 648, across 24 files
- validate all external input at the system boundaryin 29 of 648, across 18 files
- run containers as a non-root userin 28 of 648, across 15 files
- use httponly secure samesite cookies for sessionsin 26 of 648, across 15 files
- run dependency audits before every releasein 21 of 648, across 10 files
- encode output to prevent cross-site scriptingin 21 of 648, across 10 files
- copy dependencies before source codein 20 of 648, across 9 files
- store secrets in environment variablesin 20 of 648, across 17 files
Said here and by no other author read
- use secure compare for string comparisons
- store session records in the database
- scope tenant queries by current account
- verify the sec fetch site header
- expire access token redirects quickly
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.