security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
How do I install this agent skill?
npx skills add https://github.com/thinkoodle/rails-skills --skill securityIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The Rails Security Expert skill provides high-quality, secure instructions for developing Rails applications. It focuses on defensive programming, covering critical areas like SQL injection, XSS, and CSRF. The codebase auditing commands and examples of insecure code are educational in nature and correctly labeled to prevent misuse. No security threats or malicious patterns were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Rails Security Expert
Write secure Rails code by default. Security mistakes are the most dangerous mistakes an agent can make — an XSS hole or SQL injection can compromise every user instantly. When in doubt, choose the safer path.
Philosophy
Core Principles:
- Secure by default — Rails has excellent built-in protections. Never disable them without explicit, documented justification
- Defense in depth — Layer protections. Don't rely on a single mechanism
- Permit-list over deny-list — Always prefer allowed lists over blocked lists for input validation, URL schemes, HTML tags, redirect targets
- Never trust user input — Every param, header, cookie, and URL segment is attacker-controlled until proven otherwise
- Fail closed — When authorization is ambiguous, deny access. When input is suspicious, reject it
- Minimize exposure — Log less, expose less, store less. Filter sensitive params, avoid leaking stack traces, encrypt at rest
When To Use This Skill
- Adding authentication or authorization logic
- Writing controllers that accept user input
- Rendering user-generated content in views
- Building API endpoints
- Working with file uploads or downloads
- Configuring sessions, cookies, or CSRF protection
- Managing secrets and credentials
- Setting HTTP security headers or CSP
- Reviewing code for security vulnerabilities
- Fixing reported CVEs or security issues
Instructions
Step 1: Audit the Existing Security Posture
Check what's already in place before changing anything — understanding the existing security posture prevents accidentally weakening it:
# Check CSRF configuration
rg "protect_from_forgery" app/controllers/
# Check strong params usage
rg "params\.permit\|params\.require" app/controllers/
# Check for dangerous patterns
rg "\.html_safe|raw\(|raw " app/views/ app/helpers/
rg "\.where\(\".*#\{" app/ --type ruby
rg "redirect_to.*params" app/controllers/
# Check security headers
rg "content_security_policy" config/
rg "force_ssl" config/
# Check filter_parameters
cat config/initializers/filter_parameter_logging.rb
# Check credentials setup
ls config/credentials* config/master.key 2>/dev/null
Step 2: CSRF Protection
Never disable CSRF protection. Rails enables it by default — it's your primary defense against cross-site request forgery.
# ApplicationController — this should ALREADY be here
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
end
How it works:
- Every non-GET request requires an authenticity token
- Include
<%= csrf_meta_tags %>in your layout<head>for Turbo/Ajax - API controllers using token auth may skip CSRF — but only if they don't use cookie-based sessions (cookie sessions + no CSRF = attackers can forge requests)
- Never use
skip_forgery_protectionon controllers that serve browser sessions
# API-only controllers with token auth — acceptable to skip
class Api::BaseController < ActionController::API
# No cookie session = no CSRF needed
# But MUST authenticate via Authorization header
end
# WRONG — disabling CSRF on a browser-facing controller
class PaymentsController < ApplicationController
skip_forgery_protection # NEVER DO THIS
end
Step 3: XSS Prevention
Rails auto-escapes all ERB output by default. This is your primary XSS defense — bypassing it means any user-controlled string can inject scripts into other users' browsers.
# SAFE — auto-escaped (default)
<%= user.name %>
<%= user.bio %>
# DANGEROUS — bypasses escaping. Only use for trusted, sanitized HTML
<%= raw(content) %> # AVOID
<%= content.html_safe %> # AVOID
<%== content %> # AVOID
# CORRECT way to render trusted HTML — sanitize first
<%= sanitize(user.bio, tags: %w[p br strong em a ul ol li], attributes: %w[href title]) %>
html_safe discipline:
- NEVER call
.html_safeon user input — this is an instant XSS hole - NEVER call
.html_safeon strings built with interpolation of user data — the interpolated values bypass escaping - Only use
.html_safeon string literals you fully control - When building HTML in helpers, use
tagbuilder orcontent_tag— they handle escaping automatically
# WRONG — XSS vulnerability
def greeting(user)
"<h1>Hello, #{user.name}</h1>".html_safe
end
# CORRECT — use tag helpers
def greeting(user)
tag.h1("Hello, #{user.name}")
end
# CORRECT — escape manually if you must build strings
def greeting(user)
"<h1>Hello, #{ERB::Util.html_escape(user.name)}</h1>".html_safe
end
sanitize() usage:
- Specify allowed tags and attributes explicitly — the default allowlist may be broader than you need
- For rich text, prefer Action Text which handles sanitization automatically
Step 4: SQL Injection Prevention
Never interpolate user input into SQL strings. Interpolation lets attackers inject arbitrary SQL — they can read, modify, or delete any data in your database.
# DANGEROUS — SQL injection
User.where("name = '#{params[:name]}'")
User.where("email LIKE '%#{params[:q]}%'")
Order.where("status = '#{status}' AND user_id = #{user_id}")
# SAFE — parameterized queries
User.where("name = ?", params[:name])
User.where("email LIKE ?", "%#{User.sanitize_sql_like(params[:q])}%")
Order.where(status: status, user_id: user_id)
# SAFE — named parameters
User.where("name = :name AND role = :role", name: params[:name], role: params[:role])
# SAFE — hash conditions (preferred)
User.where(name: params[:name])
LIKE queries need special handling:
# WRONG — user can inject % and _ wildcards
User.where("name LIKE ?", "%#{params[:q]}%")
# CORRECT — sanitize LIKE wildcards
User.where("name LIKE ?", "%#{User.sanitize_sql_like(params[:q])}%")
Less obvious injection vectors — these methods also pass strings directly to SQL:
order()— never pass raw user input (attackers can inject subqueries)pluck(),select(),group(),having(),joins()with string argsfind_by_sql(),connection.execute()Arel.sql()— only use with trusted strings, never user input
# WRONG — order injection
Post.order(params[:sort])
# CORRECT — permit-list of allowed columns
allowed = %w[created_at title updated_at]
direction = %w[asc desc].include?(params[:dir]) ? params[:dir] : "asc"
column = allowed.include?(params[:sort]) ? params[:sort] : "created_at"
Post.order("#{column} #{direction}")
Step 5: Mass Assignment Protection
Use strong parameters for every model write. Raw params let attackers set any attribute — including admin flags, roles, and associations they shouldn't touch.
# WRONG — mass assignment vulnerability
User.create(params[:user])
@user.update(params[:user])
User.new(params.permit!) # permit! allows everything
# CORRECT — strong parameters
def user_params
params.require(:user).permit(:name, :email, :bio)
end
@user.update(user_params)
Strong params guidelines:
- Define a private
*_paramsmethod in every controller - Only permit attributes the user should be able to set
- NEVER permit
:role,:admin,:is_admin,:verifiedor similar privilege attributes from user input — this is how privilege escalation happens - Nested attributes need explicit permitting
def post_params
params.require(:post).permit(
:title, :body, :published,
tags: [],
images_attributes: [:id, :url, :caption, :_destroy]
)
end
Step 6: Authentication
Passwordless email-code auth (Basecamp, Fizzy, Herald): do not use the Rails 8 password generator. Follow the magic-link-auth skill — codes for humans, session/pending/access tokens for machines.
Password-based apps: start from the Rails 8+ authentication generator:
bin/rails generate authentication
Key authentication practices:
- Use
has_secure_password— it handles bcrypt hashing correctly - Use
authenticate_by— it's timing-safe, preventing attackers from enumerating valid accounts by measuring response time - Call
reset_sessionafter login to prevent session fixation (where an attacker pre-sets a session ID) - Require current password for password/email changes
- Use generic error messages: "Invalid email or password" (not "User not found") — specific messages reveal which accounts exist
# Session creation — correct pattern
class SessionsController < ApplicationController
def create
if user = User.authenticate_by(
email_address: params[:email_address],
password: params[:password]
)
reset_session # Prevent session fixation
start_new_session_for user
redirect_to root_path
else
redirect_to new_session_path, alert: "Invalid email or password."
end
end
end
Rate limiting login attempts:
class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create
end
Step 7: Authorization
Scope queries to the current user. Without scoping, any authenticated user can access any record just by changing the ID in the URL.
# WRONG — any user can access any project by changing the ID
@project = Project.find(params[:id])
# CORRECT — scope to current user
@project = Current.user.projects.find(params[:id])
# CORRECT — use policy objects (Pundit, Action Policy, etc.)
@project = authorize Project.find(params[:id])
Authorization checklist:
- Scope every query to the authenticated user's permissions
- Check authorization on every action (before_action)
- Use
before_action :authenticatefor all protected resources - Prefer deny-by-default:
before_actionin ApplicationController with selectiveskip_before_actionis safer than opt-inonly:(new actions are protected by default instead of accidentally exposed)
class ApplicationController < ActionController::Base
before_action :require_authentication
# All controllers require auth unless explicitly skipped
end
class PublicPagesController < ApplicationController
skip_before_action :require_authentication, only: [:home, :about]
end
Step 8: Session Security
# config/environments/production.rb
config.force_ssl = true # HSTS + secure cookies + HTTPS redirect
# Session cookie settings (Rails defaults are good, but verify)
Rails.application.config.session_store :cookie_store,
key: "_myapp_session",
secure: Rails.env.production?,
httponly: true,
same_site: :lax
Session guidelines:
- Store only IDs in the session — sensitive data in cookies is readable even when signed
- Call
reset_sessionafter login (prevents session fixation) - Implement session expiry (absolute + idle timeout)
force_ssl = truein production — without it, session cookies travel over plain HTTP and can be stolen on public networks
Step 9: Secrets and Credentials
Use Rails credentials or environment variables for secrets. Hardcoded secrets end up in version control, where anyone with repo access (or a leaked backup) gets your API keys.
# Edit credentials
EDITOR=vim bin/rails credentials:edit
# Environment-specific credentials
EDITOR=vim bin/rails credentials:edit --environment production
# Access credentials
Rails.application.credentials.secret_key_base
Rails.application.credentials.dig(:aws, :access_key_id)
# Use bang version to fail loud if missing
Rails.application.credentials.some_api_key!
Key management:
- NEVER commit
config/master.keyorconfig/credentials/*.key— these decrypt all your secrets - Add them to
.gitignore(Rails does this by default, but verify) - Use
ENV["RAILS_MASTER_KEY"]in production - Avoid logging or exposing credentials in error messages
- Rotate secrets immediately if they may have been exposed
Step 10: Content Security Policy
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :self, :https
policy.font_src :self, :https, :data
policy.img_src :self, :https, :data
policy.object_src :none
policy.script_src :self, :https
policy.style_src :self, :https
policy.connect_src :self, :https
policy.frame_ancestors :none
policy.report_uri "/csp-violation-report-endpoint"
end
# Use nonces instead of 'unsafe-inline'
Rails.application.config.content_security_policy_nonce_generator =
->(request) { SecureRandom.base64(16) }
Rails.application.config.content_security_policy_nonce_directives = %w[script-src]
Layout must include:
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= javascript_include_tag "application", nonce: true %>
<%= stylesheet_link_tag "application", nonce: true %>
</head>
Step 11: Logging Security
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp,
:ssn, :credit_card, :card_number, :cvv, :authorization
]
Avoid logging (these show up in log files, error trackers, and potentially third-party services):
- Passwords, tokens, API keys, secrets
- Credit card numbers, SSNs, PII
- Full request bodies that may contain sensitive data
Step 12: Redirect Security
# WRONG — open redirect vulnerability
redirect_to params[:return_to]
# CORRECT — only allow internal paths
redirect_to safe_redirect_path(params[:return_to])
# Helper implementation
def safe_redirect_path(path)
uri = URI.parse(path.to_s)
if uri.relative? && uri.path.start_with?("/")
uri.path
else
root_path
end
rescue URI::InvalidURIError
root_path
end
# SIMPLEST — use url_from (Rails 7+)
redirect_to url_from(params[:return_to]) || root_path
Step 13: File Upload Security
# Validate content type
class Document < ApplicationRecord
has_one_attached :file
validates :file, content_type: %w[application/pdf image/png image/jpeg],
size: { less_than: 10.megabytes }
end
# NEVER serve uploads from your app domain in production
# Use a separate domain/CDN or Active Storage's redirect mode
# NEVER use user-provided filenames for storage
# Active Storage handles this — it uses random keys
File upload guidelines:
- Validate content type AND file extension — attackers can spoof one but rarely both
- Set maximum file size limits to prevent denial-of-service via large uploads
- Store uploads outside the web root (Active Storage does this automatically)
- Never execute uploaded files
- Process images/media asynchronously — image processing libraries can have vulnerabilities
- Scan for malware in sensitive applications
Quick Reference
Dangerous Patterns to Grep For
# XSS: rg "\.html_safe|raw\(|<%==" app/
# SQL injection: rg '\.where\(".*#\{' app/ --type ruby
# Order injection: rg '\.order\(params' app/ --type ruby
# Mass assignment: rg 'params\.permit!' app/controllers/
# Open redirects: rg 'redirect_to.*params' app/controllers/
# CSRF disabled: rg 'skip_forgery_protection' app/controllers/
# Hardcoded secrets: rg '(api_key|secret|password|token)\s*=' app/ config/ -i
# Command injection: rg 'system\(|exec\(|`.*#\{' app/ --type ruby
Regex Gotcha
# WRONG — ^ and $ match LINE boundaries in Ruby, not string boundaries
validates :url, format: { with: /^https?:\/\/.+$/ }
# CORRECT — use \A and \z for string boundaries
validates :url, format: { with: /\Ahttps?:\/\/.+\z/ }
Anti-Patterns — Each of These Is a Vulnerability
html_safeon user input — Instant XSS. Attackers inject scripts that run in other users' browsers- String interpolation in SQL — SQL injection lets attackers read/modify/delete your entire database
params.permit!— Permits everything, letting attackers set admin flags, roles, or any attributeskip_forgery_protectionon browser controllers — Lets malicious sites submit forms on behalf of your usersredirect_to params[:url]— Open redirect. Attackers craft links that look like your site but redirect to phishing pages- Hardcoded secrets in source — Anyone with repo access (or a leaked backup) gets your API keys
Kernel#openwith user input —open("| rm -rf /")executes shell commands. UseFile.open,URI.open, orIO.open- Disabling
force_sslin production — Session cookies travel in plaintext, stealable on any shared network - Logging sensitive data — Logs end up in error trackers, log aggregators, and developer machines
- Trusting client-side validation — Attackers bypass JavaScript trivially; server-side validation is the real gate
Security Audit Checklist
Before shipping any feature, verify:
- CSRF protection enabled (not skipped)
- Strong parameters used in every controller action
- No
.html_safeorraw()on user-supplied content - All SQL uses parameterized queries
- Queries scoped to current user (authorization)
- Redirects validated (no open redirects)
- Sensitive params filtered from logs
- File uploads validated (type, size)
- No hardcoded secrets in source
-
force_ssl = truein production - CSP headers configured
- Authentication uses timing-safe comparison
- Session reset after login
- Rate limiting on login/sensitive endpoints
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/security">View security on skillZs</a>