Every Rails app you deploy is a target. The moment it's on a VPS with a public IP, bots will probe it. This post covers the security essentials every Rails developer needs to know — not theory, but the actual attacks and the actual defenses.
CSRF — Cross-Site Request Forgery
Rails protects you by default, but you need to understand why.
CSRF tricks a logged-in user's browser into making requests to your app. Rails prevents this with authenticity tokens embedded in every form.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
end
Every form Rails generates includes a hidden token:
<%= form_with model: @post do |f| %>
<%# Rails automatically inserts authenticity_token here %>
<%= f.text_field :title %>
<%= f.submit %>
<% end %>
If you're building an API, you skip CSRF protection (APIs use token auth instead):
class Api::BaseController < ActionController::API
# No CSRF — API clients send auth tokens in headers
end
The mistake people make: Disabling CSRF globally because "it was causing errors." Don't. Fix the actual issue instead.
SQL Injection
Active Record protects you — if you use it correctly.
# SAFE — parameterized query
User.where(email: params[:email])
# Generates: SELECT * FROM users WHERE email = $1
# SAFE — parameterized string
User.where("email = ?", params[:email])
# DANGEROUS — string interpolation
User.where("email = '#{params[:email]}'")
# Attacker sends: ' OR 1=1 --
# Generates: SELECT * FROM users WHERE email = '' OR 1=1 --'
The rule is simple: never interpolate user input into SQL strings. Always use ? placeholders or hash conditions.
Check for injection vulnerabilities with Brakeman:
gem install brakeman
cd your_rails_app
brakeman
Brakeman scans your codebase and flags dangerous patterns. Run it in CI. Every time.
# Gemfile (development/test)
group :development, :test do
gem "brakeman", require: false
end
XSS — Cross-Site Scripting
XSS lets attackers inject JavaScript into your pages. Rails escapes output by default in ERB:
<%# SAFE — auto-escaped %>
<p><%= @user.bio %></p>
<%# If bio contains <script>alert('xss')</script>, it renders as text %>
<%# DANGEROUS — raw output %>
<p><%= raw @user.bio %></p>
<p><%= @user.bio.html_safe %></p>
<%# These render the script tag as actual JavaScript %>
Rules:
- Never use
raworhtml_safeon user input - If you must render HTML, sanitize it first:
# In your view
<%= sanitize @user.bio, tags: %w[b i em strong p br], attributes: %w[class] %>
- Use Content Security Policy headers to limit what scripts can run:
# config/initializers/content_security_policy.rb
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self
policy.style_src :self, :unsafe_inline
policy.img_src :self, :data, :https
policy.font_src :self
policy.connect_src :self
policy.frame_src :none
end
config.content_security_policy_nonce_generator = ->(request) {
SecureRandom.base64(16)
}
end
Secure Headers
Beyond CSP, set these headers. The secure_headers gem makes it easy:
# Gemfile
gem "secure_headers"
# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
config.x_frame_options = "DENY"
config.x_content_type_options = "nosniff"
config.x_xss_protection = "0" # Modern browsers use CSP instead
config.referrer_policy = %w[strict-origin-when-cross-origin]
config.hsts = "max-age=631138519; includeSubDomains"
end
Or set them manually in Nginx on your VPS:
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=631138519; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Strong Parameters
Never trust user input. Strong params whitelist what's allowed:
class PostsController < ApplicationController
def create
@post = Post.new(post_params)
# ...
end
private
def post_params
params.require(:post).permit(:title, :body, :published)
# Only these three fields pass through
# Everything else is silently dropped
end
end
Common mistake: Permitting :admin or :role fields. An attacker just adds &user[admin]=true to the request.
Environment Secrets
Never commit secrets. Use Rails credentials:
# Edit encrypted credentials
EDITOR="vim" bin/rails credentials:edit
# Access in code
Rails.application.credentials.openai_api_key
Rails.application.credentials.dig(:aws, :secret_key)
The config/credentials.yml.enc file is encrypted. The config/master.key decrypts it. Add master.key to .gitignore (Rails does this by default).
On your VPS, set the master key as an environment variable:
export RAILS_MASTER_KEY=your-master-key-here
The Security Checklist
Run this before every deploy:
- Brakeman — static analysis for vulnerabilities
- bundle audit — check gems for known CVEs
- Strong params — every controller action
- CSP headers — configured and tested
- HTTPS only — force SSL in production
# config/environments/production.rb
config.force_ssl = true
# Check gems for vulnerabilities
gem install bundler-audit
bundle audit check --update
Security isn't a feature you add at the end. It's a habit you build from the start. Rails gives you excellent defaults — your job is to not turn them off.
Next up: background job patterns for AI workloads — retries, rate limiting, and dead letter queues.