Tools: Rails Security Essentials — CSRF, SQL Injection, XSS, and Secure Headers (2026)

Tools: Rails Security Essentials — CSRF, SQL Injection, XSS, and Secure Headers (2026)

CSRF — Cross-Site Request Forgery

SQL Injection

XSS — Cross-Site Scripting

Secure Headers

Strong Parameters

Environment Secrets

The Security Checklist 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. 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. Every form Rails generates includes a hidden token: If you're building an API, you skip CSRF protection (APIs use token auth instead): The mistake people make: Disabling CSRF globally because "it was causing errors." Don't. Fix the actual issue instead. Active Record protects you — if you use it correctly. The rule is simple: never interpolate user input into SQL strings. Always use ? placeholders or hash conditions. Check for injection vulnerabilities with Brakeman: Brakeman scans your codebase and flags dangerous patterns. Run it in CI. Every time. XSS lets attackers inject JavaScript into your pages. Rails escapes output by default in ERB: Beyond CSP, set these headers. The secure_headers gem makes it easy: Or set them manually in Nginx on your VPS: Never trust user input. Strong params whitelist what's allowed: Common mistake: Permitting :admin or :role fields. An attacker just adds &user[admin]=true to the request. Never commit secrets. Use Rails credentials: 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: Run this before every deploy: 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# app/controllers/application_controller.rb class ApplicationController < ActionController::Base protect_from_forgery with: :exception end # app/controllers/application_controller.rb class ApplicationController < ActionController::Base protect_from_forgery with: :exception end # app/controllers/application_controller.rb class ApplicationController < ActionController::Base protect_from_forgery with: :exception end <%= form_with model: @post do |f| %> <%# Rails automatically inserts authenticity_token here %> <%= f.text_field :title %> <%= f.submit %> <% end %> <%= form_with model: @post do |f| %> <%# Rails automatically inserts authenticity_token here %> <%= f.text_field :title %> <%= f.submit %> <% end %> <%= form_with model: @post do |f| %> <%# Rails automatically inserts authenticity_token here %> <%= f.text_field :title %> <%= f.submit %> <% end %> class Api::BaseController < ActionController::API # No CSRF — API clients send auth tokens in headers end class Api::BaseController < ActionController::API # No CSRF — API clients send auth tokens in headers end class Api::BaseController < ActionController::API # No CSRF — API clients send auth tokens in headers end # 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 --' # 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 --' # 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 --' gem -weight: 500;">install brakeman cd your_rails_app brakeman gem -weight: 500;">install brakeman cd your_rails_app brakeman gem -weight: 500;">install brakeman cd your_rails_app brakeman # Gemfile (development/test) group :development, :test do gem "brakeman", require: false end # Gemfile (development/test) group :development, :test do gem "brakeman", require: false end # Gemfile (development/test) group :development, :test do gem "brakeman", require: false end <%# 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 %> <%# 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 %> <%# 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 %> # In your view <%= sanitize @user.bio, tags: %w[b i em strong p br], attributes: %w[class] %> # In your view <%= sanitize @user.bio, tags: %w[b i em strong p br], attributes: %w[class] %> # In your view <%= sanitize @user.bio, tags: %w[b i em strong p br], attributes: %w[class] %> # 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 # 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 # 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 # 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 # 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 # 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 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; 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; 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; 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 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 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 # 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) # 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) # 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) export RAILS_MASTER_KEY=your-master-key-here export RAILS_MASTER_KEY=your-master-key-here export RAILS_MASTER_KEY=your-master-key-here # config/environments/production.rb config.force_ssl = true # config/environments/production.rb config.force_ssl = true # config/environments/production.rb config.force_ssl = true # Check gems for vulnerabilities gem -weight: 500;">install bundler-audit bundle audit check ---weight: 500;">update # Check gems for vulnerabilities gem -weight: 500;">install bundler-audit bundle audit check ---weight: 500;">update # Check gems for vulnerabilities gem -weight: 500;">install bundler-audit bundle audit check ---weight: 500;">update - Never use raw or html_safe on user input - If you must render HTML, sanitize it first: - Use Content Security Policy headers to limit what scripts can run: - 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