agentsclimarketplace

Rails security

Skill mickzijdel/rails-toolkit/skills/rails-security

Agent skills for working with Ruby on Rails 8+

Install
npx -y skills add mickzijdel/rails-toolkit --skill rails-security

Assembled 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_compare prevents timing attacks on the email comparison.
  • A Code.sanitize step 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:

NeedReach for
Coarse role gates (admin?, staff?)The concern + before_action above — no gem
Per-resource rules across many modelsPundit — 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.account is scoped to the request lifecycle; Current.user is 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:

MistakeWhy it's badFix
policy.script_src :unsafe_inlineAllows all inline scripts — XSS protection is goneUse nonces instead
policy.script_src "*"Allows scripts from any external originList only the domains you actually load scripts from
Omitting object_src :noneOld-style plugin injection still possibleAlways set it to :none
Skipping report-onlyBreaks the app silently on first deployAlways start in report-only mode
Missing nonce directivesImportmap / inline scripts fail in productionSet content_security_policy_nonce_directives

Security Checklist

When implementing new features, verify:

  • Authentication required? Use require_authentication (default) or explicitly allow_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_compare for secrets
  • Signed/encrypted cookies? Use cookies.signed or cookies.encrypted
  • Signed IDs for URLs? Use model.signed_id for unguessable references
  • Content Security Policy? CSP initializer present with nonce generator; rolled out via _report_only before 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.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.