| name | rails-security |
| description | Use when reviewing or implementing security in Rails applications - covers OWASP guidelines and Rails-specific security patterns |
Rails Security
Comprehensive Ruby on Rails security patterns following OWASP guidelines and industry best practices for building secure web applications.
OWASP Top 10 for Rails
A01 - Broken Access Control
Authorization with Pundit:
class PostPolicy < ApplicationPolicy
def index?
true
end
def show?
record.published? || owner?
end
def create?
user.present?
end
def update?
owner? || user.admin?
end
def destroy?
owner? || user.admin?
end
private
def owner?
user.present? && record.author_id == user.id
end
class Scope < Scope
def resolve
if user&.admin?
scope.all
elsif user
scope.where('published = ? OR author_id = ?', true, user.id)
else
scope.where(published: true)
end
end
end
end
class PostsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_post, only: [:show, :edit, :update, :destroy]
after_action :verify_authorized, except: [:index]
after_action :verify_policy_scoped, only: [:index]
def index
@posts = policy_scope(Post)
end
def show
authorize @post
end
def edit
authorize @post
end
def update
authorize @post
if @post.update(post_params)
redirect_to @post, notice: 'Post updated successfully'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @post
@post.destroy
redirect_to posts_url, notice: 'Post deleted successfully'
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body, :published)
end
end
Authorization with CanCanCan:
class Ability
include CanCan::Ability
def initialize(user)
user ||= User.new
if user.admin?
can :manage, :all
elsif user.moderator?
can :read, :all
can :manage, Post, author_id: user.id
can :update, Comment
else
can :read, Post, published: true
can :create, Post
can :manage, Post, author_id: user.id
can :manage, Comment, author_id: user.id
end
cannot :update, User do |u|
u.admin? && !user.admin?
end
end
end
class PostsController < ApplicationController
load_and_authorize_resource
def index
end
def update
if @post.update(post_params)
redirect_to @post
else
render :edit
end
end
end
Custom Policy Objects:
module Admin
class UserPolicy
attr_reader :current_user, :user
def initialize(current_user, user)
@current_user = current_user
@user = user
end
def edit?
current_user.admin? && !user.admin?
end
def destroy?
current_user.admin? && !user.admin? && user != current_user
end
def promote_to_admin?
current_user.super_admin?
end
end
end
class Admin::UsersController < Admin::BaseController
def edit
@user = User.find(params[:id])
policy = Admin::UserPolicy.new(current_user, @user)
unless policy.edit?
redirect_to admin_users_path, alert: 'Not authorized'
end
end
end
A02 - Cryptographic Failures
Secure Password Storage:
gem 'bcrypt'
class User < ApplicationRecord
has_secure_password
validates :password, length: { minimum: 12 }, if: :password_required?
validates :password, format: {
with: /\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
message: "must include uppercase, lowercase, number, and special character"
}, if: :password_required?
private
def password_required?
password_digest.nil? || password.present?
end
end
Encrypted Credentials:
rails credentials:edit
Rails.application.credentials.dig(:stripe, :secret_key)
Rails.application.credentials.database[:production][:password]
rails credentials:edit --environment production
rails credentials:edit --environment staging
Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
Encryption at Rest:
gem 'attr_encrypted'
class User < ApplicationRecord
attr_encrypted :ssn, key: Rails.application.credentials.encryption_key
encrypts :ssn
encrypts :credit_card, deterministic: true
end
config.active_record.encryption.primary_key = Rails.application.credentials.active_record_encryption[:primary_key]
config.active_record.encryption.deterministic_key = Rails.application.credentials.active_record_encryption[:deterministic_key]
config.active_record.encryption.key_derivation_salt = Rails.application.credentials.active_record_encryption[:key_derivation_salt]
Secure Token Generation:
class User < ApplicationRecord
has_secure_token :auth_token
has_secure_token :reset_password_token
def generate_password_reset_token!
self.reset_password_token = SecureRandom.urlsafe_base64(32)
self.reset_password_sent_at = Time.current
save!
end
def password_reset_expired?
reset_password_sent_at < 2.hours.ago
end
end
SecureRandom.hex(32)
SecureRandom.urlsafe_base64(32)
SecureRandom.uuid
A03 - Injection
SQL Injection Prevention:
User.where("email = '#{params[:email]}'")
User.where("name LIKE '%#{params[:query]}%'")
User.where("email = ?", params[:email])
User.where(email: params[:email])
User.where("name LIKE ?", "%#{params[:query]}%")
User.where("created_at > ? AND status = ?", 1.week.ago, 'active')
User.where("email = :email AND status = :status", email: params[:email], status: 'active')
User.where(["email = ? OR username = ?", params[:email], params[:username]])
User.where(email: params[:email], status: 'active')
column = sanitize_sql_for_conditions(params[:column])
User.where("#{column} = ?", params[:value])
users = User.arel_table
User.where(users[:email].matches("%#{params[:query]}%"))
Safe Query Building:
class User < ApplicationRecord
ALLOWED_SORT_COLUMNS = %w[created_at updated_at name email].freeze
ALLOWED_SORT_DIRECTIONS = %w[asc desc].freeze
def self.search(params)
scope = all
if params[:query].present?
scope = scope.where("name ILIKE ? OR email ILIKE ?",
"%#{sanitize_sql_like(params[:query])}%",
"%#{sanitize_sql_like(params[:query])}%")
end
if params[:status].present? && %w[active inactive].include?(params[:status])
scope = scope.where(status: params[:status])
end
if params[:sort].present? && ALLOWED_SORT_COLUMNS.include?(params[:sort])
direction = params[:direction].in?(ALLOWED_SORT_DIRECTIONS) ? params[:direction] : 'asc'
scope = scope.order("#{params[:sort]} #{direction}")
end
scope
end
end
Command Injection Prevention:
system("convert #{params[:file]} output.png")
`cat #{params[:filename]}`
system("convert", params[:file], "output.png")
system(["convert", "convert"], params[:file], "output.png")
require 'shellwords'
file = Shellwords.escape(params[:file])
system("convert #{file} output.png")
require 'mini_magick'
image = MiniMagick::Image.open(params[:file])
image.format('png')
image.write('output.png')
FileUtils.cp(source, destination)
File.read(filepath)
NoSQL Injection Prevention:
User.where(eval("{ email: '#{params[:email]}' }"))
User.where(email: params[:email])
User.where(:created_at.gte => params[:start_date])
ALLOWED_OPERATORS = %w[gt gte lt lte ne in nin].freeze
def build_query(field, operator, value)
return {} unless ALLOWED_OPERATORS.include?(operator)
{ field.to_sym.send(operator) => value }
end
A04 - Insecure Design
Rate Limiting:
gem 'rack-attack'
class Rack::Attack
throttle('limit_login_attempts_per_ip', limit: 5, period: 60.seconds) do |req|
if req.path == '/login' && req.post?
req.ip
end
end
throttle('limit_login_attempts_per_email', limit: 5, period: 60.seconds) do |req|
if req.path == '/login' && req.post?
req.params['email']&.downcase
end
end
throttle('api_requests', limit: 100, period: 15.minutes) do |req|
req.env['HTTP_AUTHORIZATION'] if req.path.start_with?('/api/')
end
blocklist('block_bad_ips') do |req|
BadIp.exists?(ip: req.ip)
end
self.throttled_responder = lambda do |env|
retry_after = (env['rack.attack.match_data'] || {})[:period]
[
429,
{ 'Content-Type' => 'application/json', 'Retry-After' => retry_after.to_s },
[{ error: 'Rate limit exceeded. Try again later.' }.to_json]
]
end
end
config.middleware.use Rack::Attack
Session Management:
Rails.application.config.session_store :cookie_store,
key: '_myapp_session',
httponly: true,
secure: Rails.env.production?,
same_site: :lax
config.session_store :cookie_store,
key: '_myapp_session',
expire_after: 4.hours
class ApplicationController < ActionController::Base
before_action :validate_session
private
def validate_session
if session[:expires_at] && session[:expires_at] < Time.current
reset_session
redirect_to login_path, alert: 'Session expired'
else
session[:expires_at] = 4.hours.from_now
end
end
def create_user_session(user)
reset_session
session[:user_id] = user.id
session[:expires_at] = 4.hours.from_now
session[:ip_address] = request.remote_ip
session[:user_agent] = request.user_agent
end
def validate_session_consistency
if session[:ip_address] != request.remote_ip ||
session[:user_agent] != request.user_agent
reset_session
redirect_to login_path, alert: 'Session invalid'
end
end
end
A05 - Security Misconfiguration
Security Headers:
gem 'secure_headers'
SecureHeaders::Configuration.default do |config|
config.x_frame_options = "DENY"
config.x_content_type_options = "nosniff"
config.x_xss_protection = "1; mode=block"
config.x_download_options = "noopen"
config.x_permitted_cross_domain_policies = "none"
config.referrer_policy = %w[origin-when-cross-origin strict-origin-when-cross-origin]
config.csp = {
default_src: %w['self'],
script_src: %w['self' 'unsafe-inline' https://cdn.example.com],
style_src: %w['self' 'unsafe-inline' https://cdn.example.com],
img_src: %w['self' data: https:],
font_src: %w['self' data: https://fonts.gstatic.com],
connect_src: %w['self' https://api.example.com],
frame_ancestors: %w['none'],
base_uri: %w['self'],
form_action: %w['self'],
upgrade_insecure_requests: true
}
config.hsts = "max-age=31536000; includeSubDomains; preload"
end
class WidgetsController < ApplicationController
content_security_policy do |policy|
policy.script_src :self, :https, 'https://widget-cdn.example.com'
end
end
Environment Configuration:
Rails.application.configure do
config.force_ssl = true
config.ssl_options = {
hsts: { subdomains: true, preload: true, expires: 1.year }
}
config.consider_all_requests_local = false
config.debug_exception_response_format = :api
config.session_store :cookie_store,
key: '_myapp_session',
secure: true,
httponly: true,
same_site: :strict
config.public_file_server.enabled = false
config.secret_key_base = Rails.application.credentials.secret_key_base
config.log_level = :info
end
Rails.application.configure do
config.consider_all_requests_local = true
config.force_ssl = false
config.session_store :cookie_store,
key: '_myapp_session_dev'
end
A06 - Vulnerable Components
Dependency Management:
gem 'rails', '~> 7.1.0'
gem 'devise', '~> 4.9'
gem 'pundit', '~> 2.3'
group :development do
gem 'bundler-audit'
end
bundle audit check --update
version: 2
updates:
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
Keep Rails Updated:
gem list rails
bundle update rails
A07 - Authentication Failures
Devise Configuration:
Devise.setup do |config|
config.password_length = 12..128
config.lock_strategy = :failed_attempts
config.unlock_strategy = :time
config.maximum_attempts = 5
config.unlock_in = 1.hour
config.timeout_in = 30.minutes
config.reconfirmable = true
config.stretches = Rails.env.test? ? 1 : 12
config.paranoid = true
config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
end
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :timeoutable,
:trackable, :omniauthable
validates :password, password_strength: true
end
Custom Authentication:
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 12 }, allow_nil: true
def increment_failed_attempts!
increment!(:failed_attempts)
lock_account! if failed_attempts >= 5
end
def reset_failed_attempts!
update!(failed_attempts: 0, locked_at: nil)
end
def lock_account!
update!(locked_at: Time.current)
end
def locked?
locked_at.present? && locked_at > 1.hour.ago
end
def send_password_reset!
self.reset_password_token = SecureRandom.urlsafe_base64(32)
self.reset_password_sent_at = Time.current
save!
UserMailer.password_reset(self).deliver_later
end
def password_reset_valid?
reset_password_sent_at.present? && reset_password_sent_at > 2.hours.ago
end
end
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email]&.downcase)
if user&.locked?
flash.now[:alert] = 'Account is locked. Please try again later.'
render :new, status: :unprocessable_entity
return
end
if user&.authenticate(params[:password])
user.reset_failed_attempts!
create_user_session(user)
redirect_to root_path, notice: 'Logged in successfully'
else
user&.increment_failed_attempts!
flash.now[:alert] = 'Invalid email or password'
render :new, status: :unprocessable_entity
end
end
private
def create_user_session(user)
reset_session
session[:user_id] = user.id
session[:expires_at] = 4.hours.from_now
end
end
Multi-Factor Authentication:
gem 'rotp'
gem 'rqrcode'
class User < ApplicationRecord
has_secure_password
def enable_two_factor!
self.otp_secret = ROTP::Base32.random
save!
end
def verify_otp(code)
return false if otp_secret.blank?
totp = ROTP::TOTP.new(otp_secret)
totp.verify(code, drift_behind: 30, drift_ahead: 30)
end
def provisioning_uri
totp = ROTP::TOTP.new(otp_secret, issuer: 'MyApp')
totp.provisioning_uri(email)
end
def qr_code
RQRCode::QRCode.new(provisioning_uri)
end
end
class TwoFactorController < ApplicationController
before_action :authenticate_user!
def setup
current_user.enable_two_factor!
@qr_code = current_user.qr_code
end
def verify
if current_user.verify_otp(params[:code])
current_user.update!(two_factor_enabled: true)
redirect_to root_path, notice: '2FA enabled successfully'
else
flash.now[:alert] = 'Invalid code'
render :setup
end
end
end
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
if user.two_factor_enabled?
session[:pending_2fa_user_id] = user.id
redirect_to two_factor_verify_path
else
create_user_session(user)
redirect_to root_path
end
else
flash.now[:alert] = 'Invalid credentials'
render :new
end
end
def verify_two_factor
user = User.find(session[:pending_2fa_user_id])
if user.verify_otp(params[:code])
session.delete(:pending_2fa_user_id)
create_user_session(user)
redirect_to root_path
else
flash.now[:alert] = 'Invalid 2FA code'
render :two_factor
end
end
end
A08 - Software and Data Integrity
Secure File Uploads with Active Storage:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: us-east-1
bucket: myapp-uploads
config.active_storage.service = :amazon
class User < ApplicationRecord
has_one_attached :avatar
has_many_attached :documents
validates :avatar, content_type: ['image/png', 'image/jpeg'],
size: { less_than: 5.megabytes }
end
class FileContentTypeValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
allowed_types = options[:in]
if value.attached?
unless allowed_types.include?(value.content_type)
record.errors.add(attribute, "must be one of: #{allowed_types.join(', ')}")
end
end
end
end
class Document < ApplicationRecord
has_one_attached :file
validates :file, presence: true,
file_content_type: { in: %w[application/pdf image/png image/jpeg] },
file_size: { less_than: 10.megabytes }
before_save :scan_for_malware
private
def scan_for_malware
return unless file.attached?
scanner = ClamAV::Scanner.new
result = file.open { |f| scanner.scan(f) }
if result.virus?
errors.add(:file, 'contains malware')
throw :abort
end
end
end
class DirectUploadsController < ActiveStorage::DirectUploadsController
before_action :authenticate_user!
def create
unless valid_content_type?(params[:blob][:content_type])
render json: { error: 'Invalid file type' }, status: :unprocessable_entity
return
end
unless valid_file_size?(params[:blob][:byte_size])
render json: { error: 'File too large' }, status: :unprocessable_entity
return
end
super
end
private
def valid_content_type?(content_type)
%w[image/png image/jpeg application/pdf].include?(content_type)
end
def valid_file_size?(size)
size <= 10.megabytes
end
end
Image Processing Security:
gem 'image_processing'
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_limit: [100, 100]
attachable.variant :medium, resize_to_limit: [300, 300]
end
after_commit :process_avatar, on: [:create, :update]
private
def process_avatar
return unless avatar.attached?
ImageProcessingJob.perform_later(avatar)
end
end
class ImageProcessingJob < ApplicationJob
def perform(attachment)
return unless attachment.image?
attachment.open do |file|
image = MiniMagick::Image.new(file.path)
image.auto_orient
image.strip
image.write(file.path)
end
end
end
A09 - Security Logging Failures
Comprehensive Logging:
config.log_level = :info
config.log_tags = [:request_id, :remote_ip]
class ApplicationController < ActionController::Base
before_action :log_request_info
after_action :log_response_info
private
def log_request_info
Rails.logger.info({
request_id: request.uuid,
method: request.method,
path: request.path,
ip: request.remote_ip,
user_id: current_user&.id,
user_agent: request.user_agent,
timestamp: Time.current
}.to_json)
end
def log_response_info
Rails.logger.info({
request_id: request.uuid,
status: response.status,
duration: request_duration
}.to_json)
end
def log_authentication_event(event, user, success:, reason: nil)
Rails.logger.info({
event: event,
user_id: user&.id,
email: user&.email,
success: success,
reason: reason,
ip: request.remote_ip,
user_agent: request.user_agent,
timestamp: Time.current
}.to_json)
end
rescue_from Pundit::NotAuthorizedError do |exception|
Rails.logger.warn({
event: 'authorization_failure',
user_id: current_user&.id,
policy: exception.policy.class.name,
query: exception.query,
record: exception.record.class.name,
ip: request.remote_ip,
timestamp: Time.current
}.to_json)
redirect_to root_path, alert: 'Not authorized'
end
end
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
log_authentication_event('login', user, success: true)
create_user_session(user)
redirect_to root_path
else
log_authentication_event('login', user, success: false, reason: 'invalid_credentials')
flash.now[:alert] = 'Invalid credentials'
render :new
end
end
def destroy
log_authentication_event('logout', current_user, success: true)
reset_session
redirect_to root_path
end
end
Security Monitoring:
class SecurityEvent < ApplicationRecord
EVENTS = %w[
login_success login_failure logout
password_reset_request password_reset_success
account_locked account_unlocked
authorization_failure
suspicious_activity
].freeze
validates :event_type, inclusion: { in: EVENTS }
scope :recent, -> { where('created_at > ?', 1.week.ago) }
scope :failures, -> { where('event_type LIKE ?', '%failure%') }
def self.log_event(event_type, user_id: nil, ip_address: nil, metadata: {})
create!(
event_type: event_type,
user_id: user_id,
ip_address: ip_address,
metadata: metadata,
created_at: Time.current
)
end
def self.detect_brute_force(ip_address)
failures.where(ip_address: ip_address)
.where('created_at > ?', 10.minutes.ago)
.count > 10
end
end
class ApplicationController < ActionController::Base
before_action :check_for_suspicious_activity
private
def check_for_suspicious_activity
if SecurityEvent.detect_brute_force(request.remote_ip)
SecurityEvent.log_event('suspicious_activity', ip_address: request.remote_ip)
render plain: 'Access denied', status: :forbidden
end
end
end
A10 - Server-Side Request Forgery (SSRF)
URL Validation:
class UrlFetcher
ALLOWED_SCHEMES = %w[http https].freeze
BLOCKED_IPS = [
IPAddr.new('127.0.0.0/8'),
IPAddr.new('10.0.0.0/8'),
IPAddr.new('172.16.0.0/12'),
IPAddr.new('192.168.0.0/16'),
IPAddr.new('169.254.0.0/16'),
IPAddr.new('::1'),
IPAddr.new('fc00::/7')
].freeze
def self.fetch(url)
uri = URI.parse(url)
unless ALLOWED_SCHEMES.include?(uri.scheme)
raise SecurityError, "Invalid URL scheme: #{uri.scheme}"
end
validate_host!(uri.host)
response = Net::HTTP.start(
uri.host,
uri.port,
use_ssl: uri.scheme == 'https',
open_timeout: 5,
read_timeout: 10
) do |http|
http.get(uri.request_uri)
end
response.body
rescue => e
Rails.logger.error("URL fetch failed: #{e.message}")
raise
end
def self.validate_host!(host)
addresses = Resolv.getaddresses(host)
addresses.each do |address|
ip = IPAddr.new(address)
BLOCKED_IPS.each do |blocked|
if blocked.include?(ip)
raise SecurityError, "Access to #{address} is not allowed"
end
end
end
rescue Resolv::ResolvError => e
raise SecurityError, "Cannot resolve hostname: #{host}"
end
end
class WebhooksController < ApplicationController
def create
url = params[:callback_url]
begin
UrlFetcher.fetch(url)
render json: { success: true }
rescue SecurityError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
end
Cross-Site Scripting (XSS) Prevention
Output Escaping
Safe Rendering:
<%
<%= @user.name %>
<%= @post.title %>
<%
<%= @user.bio.html_safe %>
<%= raw @post.content %>
<%= sanitize @post.content %>
<%= sanitize @post.content, tags: %w[p br strong em], attributes: %w[href] %>
class ApplicationSanitizer < Rails::Html::SafeListSanitizer
def allowed_tags
%w[p br strong em ul ol li a]
end
def allowed_attributes
%w[href title]
end
end
<%= ApplicationSanitizer.new.sanitize(@post.content) %>
Safe Helpers:
module ApplicationHelper
def user_badge(user)
content_tag :span, user.name, class: 'badge'
end
def safe_link_to(text, url, options = {})
uri = URI.parse(url)
return content_tag(:span, text) unless %w[http https mailto].include?(uri.scheme)
link_to text, url, options
rescue URI::InvalidURIError
content_tag(:span, text)
end
def safe_markdown(text)
markdown = Redcarpet::Markdown.new(
Redcarpet::Render::HTML.new(filter_html: true, hard_wrap: true),
autolink: true,
space_after_headers: true,
fenced_code_blocks: true,
no_intra_emphasis: true
)
sanitize markdown.render(text),
tags: %w[p br strong em ul ol li code pre a h1 h2 h3],
attributes: %w[href]
end
end
JavaScript Context:
<%# BAD: Interpolating into JavaScript %>
<script>
var name = '<%= @user.name %>'; // VULNERABLE!
</script>
<%# GOOD: Use JSON encoding %>
<script>
var user = <%= raw @user.to_json %>;
var name = <%= @user.name.to_json %>;
</script>
<%# Better: Use data attributes %>
<div id="user-widget" data-user="<%= @user.to_json %>"></div>
<script>
var userData = JSON.parse(document.getElementById('user-widget').dataset.user);
</script>
Content Security Policy
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :none
policy.script_src :self, :unsafe_inline
policy.style_src :self, :unsafe_inline
policy.img_src :self, :data, :https
policy.font_src :self, :data
policy.connect_src :self
policy.frame_ancestors :none
policy.base_uri :self
policy.form_action :self
end
config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src]
config.content_security_policy_report_only = false
config.content_security_policy_report_uri = '/csp-violations'
end
<script nonce="<%= content_security_policy_nonce %>">
// Inline script
</script>
Cross-Site Request Forgery (CSRF)
Rails CSRF Protection
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
end
<%= form_with model: @post do |f| %>
<%
<% end %>
<meta name="csrf-token" content="<%= form_authenticity_token %>">
const csrfToken = document.querySelector('[name="csrf-token"]').content;
fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(data)
});
class Api::BaseController < ActionController::Base
protect_from_forgery with: :null_session
end
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:create]
before_action :verify_webhook_signature, only: [:create]
private
def verify_webhook_signature
signature = request.headers['X-Webhook-Signature']
expected = OpenSSL::HMAC.hexdigest('SHA256', webhook_secret, request.body.read)
unless Rack::Utils.secure_compare(signature, expected)
head :unauthorized
end
end
end
API Security
JWT Authentication
gem 'jwt'
class JsonWebToken
SECRET_KEY = Rails.application.credentials.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')[0]
HashWithIndifferentAccess.new(decoded)
rescue JWT::DecodeError => e
nil
end
end
class Api::V1::AuthenticationController < Api::BaseController
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token, user: user }, status: :ok
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
end
class Api::BaseController < ActionController::API
before_action :authenticate_request
private
def authenticate_request
header = request.headers['Authorization']
token = header.split(' ').last if header
decoded = JsonWebToken.decode(token)
if decoded
@current_user = User.find(decoded[:user_id])
else
render json: { error: 'Unauthorized' }, status: :unauthorized
end
rescue ActiveRecord::RecordNotFound
render json: { error: 'Unauthorized' }, status: :unauthorized
end
attr_reader :current_user
end
API Rate Limiting
throttle('api_per_user', limit: 1000, period: 1.hour) do |req|
if req.path.start_with?('/api/') && req.env['current_user']
req.env['current_user'].id
end
end
throttle('api_expensive_endpoint', limit: 10, period: 1.hour) do |req|
if req.path == '/api/v1/reports' && req.post?
req.env['current_user']&.id || req.ip
end
end
API Versioning
namespace :api do
namespace :v1 do
resources :posts
end
namespace :v2 do
resources :posts
end
end
module Api
module V1
class PostsController < Api::BaseController
def index
posts = Post.all
render json: posts, each_serializer: Api::V1::PostSerializer
end
end
end
end
Secrets Management
Encrypted Credentials
rails credentials:edit
rails credentials:edit --environment production
rails credentials:edit --environment staging
Access Credentials:
Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
Rails.application.credentials.database[:production][:password]
Environment Variables
gem 'dotenv-rails', groups: [:development, :test]
DATABASE_URL=postgresql://localhost/myapp
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_xxx
# .env.example (commit this)
DATABASE_URL=
REDIS_URL=
STRIPE_SECRET_KEY=
# Access in code
ENV['STRIPE_SECRET_KEY']
ENV.fetch('REDIS_URL', 'redis://localhost:6379')
# Validate required env vars
# config/initializers/env_validator.rb
required_env_vars = %w[
DATABASE_URL
SECRET_KEY_BASE
STRIPE_SECRET_KEY
]
required_env_vars.each do |var|
unless ENV[var].present?
raise "Missing required environment variable: #{var}"
end
end
Best Practices Summary
Security Checklist
Security Anti-Patterns to Avoid
- Using
raw or html_safe without sanitization
- String interpolation in SQL queries
- Disabling CSRF protection without alternative
- Storing secrets in code or version control
- Not validating file uploads
- Missing rate limiting on authentication endpoints
- Weak password requirements
- Not logging security events
- Exposing detailed error messages in production
- Using outdated dependencies
Regular Security Tasks
bundle audit check --update
bundle update --conservative
rails security:audit
- Review authorization policies
- Audit user permissions
- Review security logs
- Update security dependencies
Additional Resources