You've built your Rails app. It works. Users are signing up. Data is flowing.
But is it secure?
Most developers assume Rails protects them by default. It does, to some extent. But security is not automatic. It requires constant attention.
Let me show you how to secure your Rails application against real threats.
The Short Answer
Rails gives you a solid security foundation. But you need to build on it.
| Threat | Rails Protection | Your Responsibility |
|---|---|---|
| SQL Injection | Strong | Use parameterized queries |
| XSS | Strong | Sanitize user input |
| CSRF | Built-in | Keep it enabled |
| Mass Assignment | Strong | Use strong parameters |
| Authentication | None | Implement properly |
| Authorization | None | Implement properly |
Don't assume Rails protects you from everything. It doesn't.
Part 1: Authentication
Authentication is the first line of defense. Do it right.
Use Devise or Built-in Authentication
Rails 7 includes built-in authentication:
rails generate authentication
This gives you:
- Secure password hashing (bcrypt)
- Session management
- Password reset flow
If you need more features, use Devise:
# Gemfile
gem "devise", "~> 4.9"
rails generate devise:install
rails generate devise User
rails db:migrate
Don't Roll Your Own Auth
Never write your own authentication from scratch.
# ❌ Don't do this
class UsersController < ApplicationController
def create
user = User.new(params)
if params[:password] == params[:password_confirmation]
# ... bad security
end
end
end
# ✅ Use a battle-tested solution
# Devise or Rails built-in authentication
Secure Password Storage
# Gemfile
gem "bcrypt", "~> 3.1"
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :password, length: { minimum: 8 }, if: -> { password.present? }
end
Rate Limiting Login Attempts
# config/initializers/rack_attack.rb
class Rack::Attack
throttle("login/ip", limit: 20, period: 1.minute) do |req|
req.ip if req.path == "/users/sign_in" && req.post?
end
throttle("login/email", limit: 5, period: 1.hour) do |req|
req.params["user"]["email"] if req.path == "/users/sign_in" && req.post?
end
end
Part 2: Authorization
Authentication tells you who the user is. Authorization tells you what they can do.
Use Pundit or CanCanCan
Pundit is simple and works well:
# Gemfile
gem "pundit", "~> 2.3"
rails generate pundit:install
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def update?
user.admin? || user == record.user
end
def destroy?
user.admin? || user == record.user
end
end
# app/controllers/posts_controller.rb
def update
authorize @post
@post.update(post_params)
end
Check Authorization in Views
<% if policy(@post).update? %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
Don't Rely on Hide/Show
Never rely on hiding UI elements for security. Always authorize on the server side.
# ❌ Bad - hiding button only
<% if current_user.admin? %>
<%= link_to "Delete", post_path(@post), method: :delete %>
<% end %>
# ✅ Good - authorize on server
class PostsController < ApplicationController
def destroy
authorize @post
@post.destroy
end
end
Part 3: SQL Injection
Rails protects you from SQL injection... if you use it correctly.
Use Parameterized Queries
# ❌ Vulnerable - never do this
User.where("email = '#{params[:email]}'")
# ✅ Safe - parameterized query
User.where("email = ?", params[:email])
# ✅ Safe - Rails style
User.where(email: params[:email])
Be Careful with Raw SQL
# ❌ Vulnerable if user input is included
User.find_by_sql("SELECT * FROM users WHERE email = '#{params[:email]}'")
# ✅ Safe
User.find_by_sql(["SELECT * FROM users WHERE email = ?", params[:email]])
Use Sanitize for Dynamic Table/Column Names
# If you must use dynamic column names
order = params[:sort] || "created_at"
direction = params[:direction] || "desc"
# Sanitize column name
allowed_columns = ["created_at", "updated_at", "name"]
order = allowed_columns.include?(order) ? order : "created_at"
direction = direction == "desc" ? "desc" : "asc"
User.order("#{order} #{direction}")
Part 4: Cross-Site Scripting (XSS)
Rails protects you from XSS... if you use it correctly.
Use .html_safe Carefully
# ❌ Dangerous - marks user input as safe
<%= user_input.html_safe %>
# ✅ Safe - sanitizes the input
<%= sanitize(user_input) %>
# ✅ Safe - Rails escapes by default
<%= user_input %>
Sanitize User-Provided HTML
# Allow only specific tags
sanitized = sanitize(user_input, tags: %w[strong em a], attributes: %w[href])
# Use ActionText for rich content
has_rich_text :content # Automatically sanitizes
Escape in JavaScript
# ❌ Dangerous
<script>
var userData = <%= user_input.to_json.html_safe %>;
</script>
# ✅ Safe
<script>
var userData = <%= user_input.to_json %>;
</script>
Part 5: Cross-Site Request Forgery (CSRF)
Rails has CSRF protection built in. Keep it enabled.
Keep CSRF Protection On
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# Keep this enabled
protect_from_forgery with: :exception
end
Include CSRF Token in Forms
<%= form_with model: @post do |f| %>
<!-- CSRF token is automatically included -->
<% end %>
<!-- For AJAX requests -->
<%= csrf_meta_tags %>
// Include token in AJAX requests
document.querySelector('meta[name="csrf-token"]').getAttribute('content')
Part 6: Mass Assignment
Rails protects you from mass assignment... if you use strong parameters.
Use Strong Parameters
# ❌ Vulnerable - allows all attributes
User.create(params[:user])
# ✅ Safe - allows only specific attributes
User.create(user_params)
private
def user_params
params.require(:user).permit(:email, :name, :password)
end
Never Use without_protection
# ❌ Dangerous
User.create(params[:user], without_protection: true)
# ❌ Dangerous in Rails 4+
User.create(params[:user]) # Without strong params
Part 7: Secure Headers
Add security headers to protect against common attacks.
Use Secure Headers Gem
# Gemfile
gem "secure_headers", "~> 6.5"
bundle install
# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
config.hsts = "max-age=31536000; includeSubdomains; preload"
config.x_frame_options = "DENY"
config.x_content_type_options = "nosniff"
config.x_xss_protection = "1; mode=block"
config.referrer_policy = "strict-origin-when-cross-origin"
config.csp = {
default_src: ["'self'"],
script_src: ["'self'", "'unsafe-inline'"],
style_src: ["'self'", "'unsafe-inline'"],
img_src: ["'self'", "https:"],
font_src: ["'self'"],
connect_src: ["'self'"]
}
end
Manual Headers in Rails
# config/initializers/security_headers.rb
Rails.application.config.action_dispatch.default_headers = {
"X-Frame-Options" => "DENY",
"X-Content-Type-Options" => "nosniff",
"X-XSS-Protection" => "1; mode=block",
"Referrer-Policy" => "strict-origin-when-cross-origin"
}
Part 8: Session Security
Secure your sessions.
Use a Secure Session Store
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
key: "_myapp_session",
secure: Rails.env.production?,
httponly: true,
same_site: :lax
Regenerate Session ID on Login
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
# Regenerate session ID
reset_session
session[:user_id] = user.id
redirect_to dashboard_path
else
render :new
end
end
end
Set Session Timeout
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
expire_after: 24.hours
Part 9: Security Scanning Tools
Automate security testing.
Brakeman
# Gemfile
group :development do
gem "brakeman"
end
brakeman -o security_report.html
Bundle Audit
# Gemfile
group :development do
gem "bundler-audit"
end
bundle audit
Dependabot
Enable Dependabot on GitHub to automatically update vulnerable dependencies.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "weekly"
Part 10: Monitoring and Logging
Monitor for suspicious activity.
Log Authentication Events
# app/controllers/sessions_controller.rb
def create
if user&.authenticate(params[:password])
Rails.logger.info("Login successful: #{user.email}")
else
Rails.logger.warn("Login failed: #{params[:email]}")
end
end
Monitor Failed Login Attempts
class UsersController < ApplicationController
before_action :check_failed_attempts
private
def check_failed_attempts
if RateLimiter.exceeded?(request.ip, "login")
render json: { error: "Too many login attempts" }, status: 429
end
end
end
Use Sentry for Error Tracking
# Gemfile
gem "sentry-ruby", "~> 5.0"
gem "sentry-rails", "~> 5.0"
# config/initializers/sentry.rb
Sentry.init do |config|
config.dsn = ENV["SENTRY_DSN"]
config.environment = Rails.env
end
The Security Checklist
Authentication
- [ ] Use Devise or Rails built-in auth
- [ ] Rate limit login attempts
- [ ] Regenerate session on login
- [ ] Set session timeout
Authorization
- [ ] Use Pundit or CanCanCan
- [ ] Authorize on server side
- [ ] Check permissions in views
Data Security
- [ ] Use parameterized queries
- [ ] Use strong parameters
- [ ] Sanitize user input
Headers
- [ ] Add HSTS header
- [ ] Set X-Frame-Options to DENY
- [ ] Set X-Content-Type-Options
- [ ] Configure Content-Security-Policy
Monitoring
- [ ] Set up error tracking
- [ ] Log suspicious activity
- [ ] Run security scans
Summary
| Tool | Purpose |
|---|---|
| Devise/Rails Auth | Authentication |
| Pundit/CanCanCan | Authorization |
| Strong Parameters | Mass assignment protection |
| Secure Headers | Security headers |
| Brakeman | Security scanning |
| Sentry | Error monitoring |
Quick Security Commands
# Run security scans
brakeman
bundle audit
# Update vulnerable gems
bundle update
# Check security headers
curl -I https://yourapp.com
Security is not a one-time task. It's an ongoing process.
Review your security regularly. Test your assumptions. Keep learning.
Your users trust you with their data. Honor that trust.
Leave a comment