Overview
Security by Design represents a proactive approach to software security where security considerations drive architectural decisions, implementation patterns, and operational procedures from project inception. This methodology contrasts with reactive security models that attempt to secure systems after development completes.
The concept emerged from recurring patterns of security failures in systems where developers treated security as a feature to add later. When security integrates into the foundation of system design, the resulting software resists attacks more effectively and requires fewer expensive retrofits. Security by Design applies to all layers of application development: architecture, data models, authentication systems, authorization logic, input handling, error management, and deployment configurations.
A system built with Security by Design considers threat models during architecture planning, validates inputs at system boundaries, enforces the principle of least privilege in authorization, and fails securely when errors occur. The approach recognizes that perfect security remains impossible but that intentional design choices significantly reduce attack surfaces and limit the impact of successful attacks.
# Security by Design: Input validation at system boundary
class UserRegistration
def initialize(params)
@email = sanitize_email(params[:email])
@password = params[:password]
end
def register
validate_email_format
validate_password_strength
check_email_uniqueness
User.create!(
email: @email,
password_digest: BCrypt::Password.create(@password)
)
end
private
def sanitize_email(email)
email.to_s.strip.downcase
end
def validate_email_format
raise ValidationError unless @email.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
end
def validate_password_strength
raise ValidationError if @password.length < 12
raise ValidationError unless @password.match?(/[A-Z]/) && @password.match?(/[0-9]/)
end
end
Key Principles
Principle of Least Privilege dictates that each component, user, and process receives only the minimum permissions necessary to perform its function. A database connection pool accessing user data requires read access to specific tables, not administrative privileges across the entire database. An API endpoint serving public product information operates under different authorization rules than an endpoint modifying inventory records.
Defense in Depth structures security as multiple independent layers. If an attacker bypasses one security control, additional controls prevent complete system compromise. Web applications implement defense in depth through input validation, parameterized queries, output encoding, content security policies, and rate limiting. Each layer provides protection, and no single layer failure compromises the entire system.
Fail Securely ensures that error conditions default to secure states rather than permissive states. When authentication fails, the system denies access rather than logging in a default user. When authorization checks encounter errors, the system refuses the operation rather than proceeding with uncertain permissions. Database connection failures prevent data access rather than bypassing access controls.
Complete Mediation requires the system to check authorization on every access to protected resources. Session-based web applications verify user permissions on each request, not just at login. API endpoints validate authentication tokens for every call. Database queries include user context in access control checks rather than relying on application-layer filtering alone.
Separation of Privileges divides security-critical operations across multiple components or actors, preventing any single compromise from achieving complete control. Financial transaction systems require approval from multiple parties for large transfers. Administrative operations require both authentication and a second factor. Cryptographic key management separates key generation, storage, and usage across different systems.
Economy of Mechanism prefers simple, auditable security implementations over complex systems. Complex security logic introduces opportunities for errors and increases the attack surface. A straightforward authentication system using established libraries and patterns provides better security than custom cryptographic implementations, regardless of developer intentions.
Open Design assumes attackers know system architecture and implementation details, forcing security to depend on keys and passwords rather than obscurity. Source code review, security audits, and penetration testing become more effective when security does not rely on hidden implementation details. Published security mechanisms allow community review and improvement.
# Multiple principles in practice
class SecureDocumentAccess
def initialize(user, document_id)
@user = user
@document_id = document_id
end
def retrieve_document
# Complete Mediation: Check authorization on every access
authorize!
# Fail Securely: Return nil rather than document on error
document = Document.find_by(id: @document_id)
return nil unless document
# Defense in Depth: Additional validation
return nil if document.deleted?
return nil if document.requires_special_permission? && !@user.has_special_permission?
# Least Privilege: Log access without exposing document content
AuditLog.create(user_id: @user.id, document_id: @document_id, action: 'view')
document
rescue StandardError => e
# Fail Securely: Log error but return nil
ErrorLog.create(error: e.message, user_id: @user.id)
nil
end
private
def authorize!
raise UnauthorizedError unless @user.can_access_document?(@document_id)
end
end
Security Implications
Authentication Security forms the first line of defense in user-facing applications. Weak authentication allows attackers to impersonate legitimate users and access protected resources. Security by Design treats authentication as a critical architectural decision requiring careful implementation, not a feature added during development.
Modern authentication systems use adaptive hashing algorithms that increase computational cost as hardware improves. BCrypt, scrypt, and Argon2 provide this adaptability. Fixed-cost algorithms like SHA-256 become vulnerable as computing power increases. Password storage never uses reversible encryption or encoding. The system stores password hashes with unique salts, preventing rainbow table attacks and making password cracking computationally expensive even when attackers obtain database dumps.
Session management requires careful attention to session fixation attacks, session hijacking, and session lifetime management. Security by Design regenerates session identifiers after authentication, invalidates sessions on logout, implements absolute and idle timeouts, and stores session data server-side rather than in client cookies. Session identifiers contain sufficient entropy to prevent guessing attacks and never appear in URL parameters.
Authorization Architecture determines who can perform which operations on which resources. Flawed authorization logic represents one of the most common security vulnerabilities in web applications. Role-based access control (RBAC) provides coarse-grained permissions based on user roles. Attribute-based access control (ABAC) enables fine-grained decisions based on user attributes, resource properties, and environmental context.
Authorization checks occur at multiple layers: application controllers verify user permissions before invoking business logic, service layers enforce authorization rules within domain operations, and database queries filter results based on user context. This defense in depth prevents authorization bypass through direct data access or API manipulation.
Input Validation and Sanitization prevent injection attacks and data corruption. Every system boundary represents a potential attack vector where malicious input can compromise security. Web applications validate and sanitize data from HTTP parameters, headers, cookies, and file uploads. APIs validate JSON payloads against schemas. Database layers use parameterized queries to prevent SQL injection.
Validation occurs early in request processing, rejecting invalid input before it reaches business logic. Allowlist validation defines accepted patterns rather than denylisting dangerous characters. An email field accepts strings matching email patterns rather than attempting to filter out SQL injection characters. Numeric fields parse and validate ranges rather than accepting arbitrary strings.
Cryptographic Key Management determines the security of encrypted data and authenticated communications. Weak key management undermines strong encryption algorithms. Keys require secure generation using cryptographically secure random number generators, secure storage separate from encrypted data, regular rotation, and secure distribution mechanisms.
Applications separate encryption keys from application code, storing them in environment variables, key management services, or hardware security modules. Development and testing environments use different keys than production systems. Compromised development keys do not affect production data security.
Error Handling and Information Disclosure balance debugging capabilities with security requirements. Detailed error messages assist development but leak sensitive information to attackers. Production systems return generic error messages to clients while logging detailed information server-side. Stack traces, database errors, and file paths never appear in client-facing error responses.
Failed authentication attempts return identical responses for invalid usernames and invalid passwords, preventing username enumeration. Authorization failures return 404 Not Found rather than 403 Forbidden when appropriate, avoiding confirmation that protected resources exist.
# Secure error handling
class SecureController < ApplicationController
rescue_from ActiveRecord::RecordNotFound do |e|
# Don't reveal whether record exists
Rails.logger.error("Record not found: #{e.message}")
render json: { error: 'Resource not found' }, status: :not_found
end
rescue_from UnauthorizedError do |e|
# Don't reveal authorization logic
Rails.logger.error("Unauthorized access attempt: #{e.message}")
render json: { error: 'Access denied' }, status: :forbidden
end
rescue_from StandardError do |e|
# Never expose internal errors
Rails.logger.error("Unexpected error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}")
render json: { error: 'An error occurred' }, status: :internal_server_error
end
end
Ruby Implementation
Rails applications implement Security by Design through built-in security features and established gems. The framework provides CSRF protection, SQL injection prevention through Active Record, XSS protection through automatic output escaping, and secure session management. Additional security requires explicit implementation using these foundations.
Authentication Implementation typically uses the bcrypt gem for password hashing and Devise or custom solutions for session management. BCrypt provides adaptive hashing with configurable computational cost:
# config/initializers/bcrypt.rb
BCrypt::Engine.cost = Rails.env.production? ? 12 : 4
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 12 },
format: { with: /\A(?=.*[A-Z])(?=.*[0-9])/ },
if: :password_digest_changed?
def self.authenticate(email, password)
user = find_by(email: email.downcase)
return nil unless user
return nil unless user.authenticate(password)
# Check account status
return nil if user.suspended?
# Update last login
user.update(last_login_at: Time.current)
user
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.authenticate(params[:email], params[:password])
if user
# Regenerate session to prevent fixation
reset_session
session[:user_id] = user.id
session[:created_at] = Time.current
redirect_to dashboard_path
else
# Same response for invalid username or password
flash.now[:alert] = 'Invalid credentials'
render :new
end
end
def destroy
reset_session
redirect_to root_path
end
end
Authorization Implementation uses gems like Pundit or CanCanCan for policy-based authorization. Pundit encourages explicit authorization policies:
# app/policies/document_policy.rb
class DocumentPolicy
attr_reader :user, :document
def initialize(user, document)
@user = user
@document = document
end
def show?
document.public? || owner? || shared_with_user?
end
def update?
owner? || collaborator?
end
def destroy?
owner?
end
private
def owner?
document.owner_id == user.id
end
def collaborator?
document.collaborators.exists?(user_id: user.id, role: 'editor')
end
def shared_with_user?
document.shares.exists?(user_id: user.id)
end
end
# app/controllers/documents_controller.rb
class DocumentsController < ApplicationController
before_action :authenticate_user!
def show
@document = Document.find(params[:id])
authorize @document
end
def update
@document = Document.find(params[:id])
authorize @document
if @document.update(document_params)
render json: @document
else
render json: { errors: @document.errors }, status: :unprocessable_entity
end
end
end
SQL Injection Prevention relies on parameterized queries through Active Record. Direct SQL requires careful parameter binding:
# Secure: Parameterized query
User.where("email = ? AND status = ?", params[:email], 'active')
# Secure: Hash conditions
User.where(email: params[:email], status: 'active')
# Insecure: String interpolation
User.where("email = '#{params[:email]}'") # NEVER DO THIS
# Secure: Raw SQL with parameters
sql = "SELECT * FROM users WHERE email = ? AND created_at > ?"
User.find_by_sql([sql, params[:email], 1.year.ago])
# Complex queries with sanitization
sanitized_column = ActiveRecord::Base.sanitize_sql(params[:sort_column])
allowed_columns = ['name', 'email', 'created_at']
column = allowed_columns.include?(sanitized_column) ? sanitized_column : 'created_at'
User.order(column)
Cross-Site Scripting (XSS) Prevention uses automatic HTML escaping in ERB templates. Manual HTML requires explicit sanitization:
# app/helpers/application_helper.rb
module ApplicationHelper
def render_user_content(content)
# Allowlist safe HTML tags
sanitize(content, tags: %w[p br strong em ul ol li a],
attributes: %w[href title])
end
def render_plain_text(text)
simple_format(h(text))
end
end
# app/views/posts/show.html.erb
<div class="post-content">
<%= render_user_content(@post.content) %>
</div>
<div class="comment">
<%= render_plain_text(@comment.text) %>
</div>
CSRF Protection comes enabled by default in Rails through token verification. API endpoints using token authentication disable CSRF protection:
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :authenticate_token!
private
def authenticate_token!
token = request.headers['Authorization']&.split(' ')&.last
decoded = JWT.decode(token, Rails.application.secret_key_base, true,
algorithm: 'HS256')
@current_user = User.find(decoded[0]['user_id'])
rescue JWT::DecodeError, ActiveRecord::RecordNotFound
render json: { error: 'Invalid token' }, status: :unauthorized
end
end
Secure File Upload validates file types, limits sizes, generates unique filenames, and stores files outside web root:
class DocumentUploader
MAX_FILE_SIZE = 10.megabytes
ALLOWED_CONTENT_TYPES = %w[
application/pdf
application/msword
application/vnd.openxmlformats-officedocument.wordprocessingml.document
].freeze
def initialize(file)
@file = file
end
def upload
validate_file!
filename = generate_secure_filename
storage_path = Rails.root.join('storage', 'documents', filename)
File.open(storage_path, 'wb') do |f|
f.write(@file.read)
end
{ path: storage_path, filename: filename }
end
private
def validate_file!
raise ValidationError, 'File required' unless @file
raise ValidationError, 'File too large' if @file.size > MAX_FILE_SIZE
content_type = Marcel::MimeType.for(@file)
unless ALLOWED_CONTENT_TYPES.include?(content_type)
raise ValidationError, 'Invalid file type'
end
end
def generate_secure_filename
extension = File.extname(@file.original_filename)
"#{SecureRandom.uuid}#{extension}"
end
end
Design Considerations
Threat Modeling identifies potential attacks during design phases. STRIDE methodology categorizes threats: Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege. Each system component undergoes threat analysis to identify vulnerabilities before implementation.
Web applications face threats from SQL injection, XSS, CSRF, session hijacking, and authentication bypass. API services confront token theft, replay attacks, rate limiting bypass, and unauthorized access. Data storage systems protect against unauthorized access, data leakage, and integrity violations. Each threat requires specific mitigations designed into system architecture.
Trust Boundaries define points where data crosses from trusted to untrusted contexts or vice versa. External user input represents the most obvious trust boundary, but internal boundaries exist between services, between application tiers, and between security contexts. Each boundary requires validation, sanitization, and authorization checks appropriate to the risk.
A microservices architecture treats inter-service communication as crossing trust boundaries even within the same deployment. Service A cannot assume Service B's data remains valid or authorized. Each service validates inputs and enforces authorization independently. This defense in depth prevents cascading failures when one service becomes compromised.
Security Configuration Management centralizes security settings and prevents scattered security decisions across the codebase. Rails applications use environment variables for secrets, explicit security headers, and security-focused initializers:
# config/initializers/security.rb
Rails.application.config.action_controller.default_protect_from_forgery = true
Rails.application.config.force_ssl = true
# Security headers
Rails.application.config.action_dispatch.default_headers.merge!(
'X-Frame-Options' => 'SAMEORIGIN',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains',
'Content-Security-Policy' => "default-src 'self'; script-src 'self' 'unsafe-inline'"
)
# Session configuration
Rails.application.config.session_store :cookie_store,
key: '_app_session',
secure: Rails.env.production?,
httponly: true,
same_site: :lax,
expire_after: 2.hours
Encryption Strategy determines what data requires encryption at rest and in transit. Sensitive personal information, financial data, authentication credentials, and personally identifiable information require encryption. The strategy specifies encryption algorithms, key management procedures, and key rotation policies.
Rails applications use Active Record Encryption for attribute-level encryption:
class User < ApplicationRecord
encrypts :ssn, deterministic: false
encrypts :email, deterministic: true # Allows unique indexes
# Custom encryption context
encrypts :credit_card, key_provider: CreditCardKeyProvider.new
end
Audit Logging records security-relevant events for investigation and compliance. Logs capture authentication attempts, authorization failures, data access, configuration changes, and security exceptions. Log entries include timestamps, user identifiers, affected resources, actions attempted, and outcomes. Logs themselves require protection against tampering and unauthorized access.
class AuditLogger
def self.log_access(user:, resource:, action:, result:)
AuditLog.create!(
user_id: user&.id,
user_email: user&.email,
resource_type: resource.class.name,
resource_id: resource.id,
action: action,
result: result,
ip_address: Current.ip_address,
user_agent: Current.user_agent,
timestamp: Time.current
)
end
end
Practical Examples
Secure API Authentication demonstrates multiple security principles working together. The implementation uses JWT tokens with short expiration times, refresh tokens stored securely, rate limiting to prevent brute force attacks, and comprehensive audit logging:
class AuthenticationService
TOKEN_EXPIRATION = 15.minutes
REFRESH_TOKEN_EXPIRATION = 7.days
def initialize(user)
@user = user
end
def generate_tokens
access_token = generate_access_token
refresh_token = generate_refresh_token
{
access_token: access_token,
refresh_token: refresh_token,
expires_in: TOKEN_EXPIRATION.to_i
}
end
def refresh_access_token(refresh_token)
decoded = verify_refresh_token(refresh_token)
return nil unless decoded
stored_token = RefreshToken.find_by(
token_digest: digest(refresh_token),
user_id: @user.id
)
return nil unless stored_token&.valid?
generate_access_token
end
private
def generate_access_token
payload = {
user_id: @user.id,
email: @user.email,
roles: @user.roles,
exp: TOKEN_EXPIRATION.from_now.to_i,
iat: Time.current.to_i,
jti: SecureRandom.uuid
}
JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
end
def generate_refresh_token
token = SecureRandom.urlsafe_base64(32)
RefreshToken.create!(
user_id: @user.id,
token_digest: digest(token),
expires_at: REFRESH_TOKEN_EXPIRATION.from_now
)
token
end
def verify_refresh_token(token)
RefreshToken.find_by(
token_digest: digest(token),
user_id: @user.id
)
end
def digest(token)
Digest::SHA256.hexdigest(token)
end
end
class RefreshToken < ApplicationRecord
belongs_to :user
validates :token_digest, presence: true, uniqueness: true
validates :expires_at, presence: true
def valid?
!revoked? && expires_at > Time.current
end
def revoke!
update!(revoked_at: Time.current)
end
end
Multi-Factor Authentication adds a second authentication factor requiring users to provide something they have (authentication app code) in addition to something they know (password):
class MfaService
def initialize(user)
@user = user
end
def enable_mfa
secret = ROTP::Base32.random
@user.update!(
mfa_secret: encrypt_secret(secret),
mfa_enabled: false # Not enabled until verified
)
{
secret: secret,
qr_code: generate_qr_code(secret),
backup_codes: generate_backup_codes
}
end
def verify_and_enable(code)
return false unless verify_code(code)
@user.update!(mfa_enabled: true)
true
end
def verify_code(code)
totp = ROTP::TOTP.new(decrypt_secret(@user.mfa_secret))
totp.verify(code, drift_behind: 30, drift_ahead: 30)
end
def verify_backup_code(code)
backup_code = @user.backup_codes.find_by(
code_digest: digest(code),
used: false
)
return false unless backup_code
backup_code.update!(used: true, used_at: Time.current)
true
end
private
def generate_backup_codes
codes = 10.times.map { SecureRandom.hex(4).upcase }
codes.each do |code|
@user.backup_codes.create!(code_digest: digest(code))
end
codes
end
def generate_qr_code(secret)
totp = ROTP::TOTP.new(secret, issuer: 'YourApp')
RQRCode::QRCode.new(totp.provisioning_uri(@user.email)).as_svg
end
def encrypt_secret(secret)
# Use Rails encrypted attributes or custom encryption
ActiveSupport::MessageEncryptor.new(
Rails.application.secret_key_base[0..31]
).encrypt_and_sign(secret)
end
def decrypt_secret(encrypted_secret)
ActiveSupport::MessageEncryptor.new(
Rails.application.secret_key_base[0..31]
).decrypt_and_verify(encrypted_secret)
end
def digest(code)
Digest::SHA256.hexdigest(code)
end
end
Secure Data Export handles sensitive data carefully during export operations, enforcing authorization, audit logging, and format sanitization:
class SecureDataExporter
ALLOWED_FORMATS = %w[csv json].freeze
RATE_LIMIT = 5.per_hour
def initialize(user, resource_class, filters)
@user = user
@resource_class = resource_class
@filters = filters
end
def export(format)
validate_export!
authorize_export!
check_rate_limit!
records = fetch_authorized_records
sanitized_data = sanitize_records(records)
log_export(records.count, format)
case format
when 'csv'
generate_csv(sanitized_data)
when 'json'
generate_json(sanitized_data)
end
end
private
def validate_export!
raise ArgumentError unless ALLOWED_FORMATS.include?(format)
raise ArgumentError if @filters[:limit].to_i > 10000
end
def authorize_export!
policy_class = "#{@resource_class}Policy".constantize
raise UnauthorizedError unless policy_class.new(@user, @resource_class).export?
end
def check_rate_limit!
key = "export:#{@user.id}"
count = Rails.cache.increment(key, 1, expires_in: 1.hour)
if count == 1
Rails.cache.write(key, 1, expires_in: 1.hour)
elsif count > RATE_LIMIT
raise RateLimitError, 'Export limit exceeded'
end
end
def fetch_authorized_records
policy_scope = "#{@resource_class}Policy::Scope".constantize
scope = policy_scope.new(@user, @resource_class).resolve
scope.where(@filters.slice(:status, :category))
.limit(@filters[:limit] || 1000)
end
def sanitize_records(records)
records.map do |record|
record.attributes.except(
'password_digest',
'mfa_secret',
'api_token',
'ssn',
'credit_card'
)
end
end
def log_export(record_count, format)
AuditLog.create!(
user_id: @user.id,
action: 'data_export',
resource_type: @resource_class.name,
details: {
format: format,
record_count: record_count,
filters: @filters
},
ip_address: Current.ip_address
)
end
def generate_csv(data)
CSV.generate do |csv|
csv << data.first.keys
data.each { |row| csv << row.values }
end
end
def generate_json(data)
JSON.pretty_generate(data)
end
end
Testing Approaches
Security Test Categories cover authentication, authorization, injection attacks, session management, and error handling. Tests verify both positive cases (authorized access succeeds) and negative cases (unauthorized access fails).
Authentication Testing verifies login mechanisms, password requirements, session creation, and account lockout:
RSpec.describe AuthenticationService do
describe '#authenticate' do
let(:user) { create(:user, password: 'SecurePass123!') }
it 'authenticates with valid credentials' do
result = AuthenticationService.authenticate(
user.email,
'SecurePass123!'
)
expect(result).to be_present
expect(result.id).to eq(user.id)
end
it 'rejects invalid password' do
result = AuthenticationService.authenticate(
user.email,
'WrongPassword'
)
expect(result).to be_nil
end
it 'rejects non-existent user' do
result = AuthenticationService.authenticate(
'nonexistent@example.com',
'SecurePass123!'
)
expect(result).to be_nil
end
it 'locks account after failed attempts' do
5.times do
AuthenticationService.authenticate(user.email, 'wrong')
end
user.reload
expect(user.locked?).to be true
result = AuthenticationService.authenticate(
user.email,
'SecurePass123!'
)
expect(result).to be_nil
end
it 'rejects suspended accounts' do
user.update(suspended: true)
result = AuthenticationService.authenticate(
user.email,
'SecurePass123!'
)
expect(result).to be_nil
end
end
end
Authorization Testing verifies policy enforcement across different user roles and ownership scenarios:
RSpec.describe DocumentPolicy do
let(:owner) { create(:user) }
let(:collaborator) { create(:user) }
let(:other_user) { create(:user) }
let(:document) { create(:document, owner: owner) }
describe '#show?' do
it 'allows owner to view' do
policy = DocumentPolicy.new(owner, document)
expect(policy.show?).to be true
end
it 'allows collaborators to view' do
document.collaborators.create(user: collaborator, role: 'viewer')
policy = DocumentPolicy.new(collaborator, document)
expect(policy.show?).to be true
end
it 'denies unauthorized users' do
policy = DocumentPolicy.new(other_user, document)
expect(policy.show?).to be false
end
it 'allows public documents to all' do
document.update(public: true)
policy = DocumentPolicy.new(other_user, document)
expect(policy.show?).to be true
end
end
describe '#update?' do
it 'allows owner to update' do
policy = DocumentPolicy.new(owner, document)
expect(policy.update?).to be true
end
it 'allows editor collaborators to update' do
document.collaborators.create(user: collaborator, role: 'editor')
policy = DocumentPolicy.new(collaborator, document)
expect(policy.update?).to be true
end
it 'denies viewer collaborators' do
document.collaborators.create(user: collaborator, role: 'viewer')
policy = DocumentPolicy.new(collaborator, document)
expect(policy.update?).to be false
end
end
end
SQL Injection Testing attempts injection attacks against database queries:
RSpec.describe 'SQL Injection Protection' do
it 'prevents injection in WHERE clauses' do
malicious_input = "' OR '1'='1"
expect {
User.where("email = '#{malicious_input}'").to_a
}.to raise_error(ActiveRecord::StatementInvalid)
# Safe parameterized version
result = User.where("email = ?", malicious_input)
expect(result).to be_empty
end
it 'prevents injection in ORDER BY' do
malicious_input = "name; DROP TABLE users--"
# Unsafe version would fail
# User.order(malicious_input)
# Safe version with allowlist
allowed_columns = %w[name email created_at]
column = allowed_columns.include?(params[:sort]) ? params[:sort] : 'created_at'
expect { User.order(column).to_a }.not_to raise_error
end
it 'sanitizes LIKE queries' do
malicious_input = "%'; DROP TABLE users--"
result = User.where("name LIKE ?", "%#{User.sanitize_sql_like(malicious_input)}%")
expect(result).to be_empty
expect(User.count).to be > 0 # Table still exists
end
end
XSS Testing verifies output encoding prevents script injection:
RSpec.describe 'XSS Protection' do
it 'escapes HTML in user content' do
post = create(:post, content: '<script>alert("XSS")</script>')
rendered = render_template('posts/show', post: post)
expect(rendered).to include('<script>')
expect(rendered).not_to include('<script>')
end
it 'allows safe HTML tags' do
post = create(:post, content: '<p>Safe <strong>content</strong></p>')
rendered = render_template('posts/show', post: post)
expect(rendered).to include('<p>')
expect(rendered).to include('<strong>')
end
it 'strips dangerous attributes' do
post = create(:post, content: '<a href="javascript:alert()">Click</a>')
rendered = render_template('posts/show', post: post)
expect(rendered).not_to include('javascript:')
end
end
Session Security Testing validates session management implementation:
RSpec.describe 'Session Security' do
it 'regenerates session on login' do
old_session_id = session.id
post '/login', params: { email: user.email, password: 'password' }
expect(session.id).not_to eq(old_session_id)
end
it 'expires sessions after timeout' do
login_as(user)
travel 3.hours do
get '/dashboard'
expect(response).to redirect_to(login_path)
end
end
it 'invalidates session on logout' do
login_as(user)
session_id = session.id
delete '/logout'
# Session should be cleared
expect(session[:user_id]).to be_nil
end
end
Common Pitfalls
Mass Assignment Vulnerabilities occur when applications allow users to set any model attribute through parameter binding. Without strong parameters, attackers modify sensitive fields like admin flags or user roles:
# Vulnerable
def create
User.create(params[:user]) # Allows any attribute
end
# Secure
def create
User.create(user_params)
end
private
def user_params
params.require(:user).permit(:email, :password, :name)
end
Timing Attacks exploit response time differences to extract information. Comparing passwords or tokens with standard equality checks reveals information through timing:
# Vulnerable to timing attacks
def valid_token?(provided, expected)
provided == expected # Early exit on first mismatch
end
# Secure constant-time comparison
def valid_token?(provided, expected)
ActiveSupport::SecurityUtils.secure_compare(provided, expected)
end
Insecure Direct Object References expose internal object identifiers allowing unauthorized access through ID manipulation:
# Vulnerable
def show
@document = Document.find(params[:id]) # No authorization check
end
# Secure
def show
@document = current_user.documents.find(params[:id])
# Or use authorization policy
authorize @document
end
Insufficient Session Expiration allows sessions to remain valid indefinitely, increasing exposure window if session tokens get compromised:
# Vulnerable
Rails.application.config.session_store :cookie_store,
key: '_app_session'
# Secure
Rails.application.config.session_store :cookie_store,
key: '_app_session',
expire_after: 2.hours
Weak Random Number Generation uses predictable pseudo-random values for security-critical operations like tokens or session identifiers:
# Vulnerable
reset_token = Random.rand(1000000).to_s
# Secure
reset_token = SecureRandom.urlsafe_base64(32)
Logging Sensitive Data writes passwords, tokens, or personal information to log files:
# Vulnerable
Rails.logger.info("User logged in: #{user.email} with password #{params[:password]}")
# Secure
Rails.application.config.filter_parameters += [:password, :token, :ssn, :credit_card]
Rails.logger.info("User logged in: #{user.email}")
Hardcoded Secrets embed API keys, passwords, or encryption keys in source code:
# Vulnerable
API_KEY = "sk_live_abc123xyz789"
# Secure
API_KEY = ENV['API_KEY'] || raise('API_KEY not configured')
XML External Entity (XXE) Attacks occur when parsing XML input without disabling external entity resolution:
# Vulnerable
doc = Nokogiri::XML(params[:xml_data])
# Secure
doc = Nokogiri::XML(params[:xml_data]) do |config|
config.noent.nonet
end
Unrestricted File Upload accepts any file type without validation, allowing executable uploads:
# Vulnerable
def upload
File.write("uploads/#{params[:file].original_filename}", params[:file].read)
end
# Secure
def upload
uploader = SecureFileUploader.new(params[:file])
uploader.validate_type!
uploader.validate_size!
uploader.save_with_unique_name
end
Broken Access Control fails to verify authorization after authentication:
# Vulnerable
def update
@user = User.find(params[:id])
@user.update(user_params) # Any authenticated user can update any user
end
# Secure
def update
@user = User.find(params[:id])
authorize @user # Verify current user can update this user
@user.update(user_params)
end
Reference
Security Principles
| Principle | Description | Implementation |
|---|---|---|
| Least Privilege | Minimum necessary permissions | Role-based access control, permission scoping |
| Defense in Depth | Multiple security layers | Input validation, parameterized queries, output encoding |
| Fail Securely | Default to deny on errors | Return nil on errors, generic error messages |
| Complete Mediation | Check authorization on every access | Authorization checks per request |
| Separation of Privileges | Divide critical operations | Multi-factor authentication, approval workflows |
| Economy of Mechanism | Prefer simple security | Use established libraries, avoid custom crypto |
| Open Design | Security not based on secrecy | Assume attackers know implementation |
Common Vulnerabilities
| Vulnerability | Risk | Mitigation |
|---|---|---|
| SQL Injection | Data breach, data loss | Parameterized queries, ORM usage |
| Cross-Site Scripting | Session hijacking, phishing | Output encoding, CSP headers |
| CSRF | Unauthorized actions | CSRF tokens, SameSite cookies |
| Authentication Bypass | Unauthorized access | Strong password hashing, MFA |
| Session Fixation | Session hijacking | Regenerate session on login |
| Mass Assignment | Privilege escalation | Strong parameters |
| Insecure Direct Object References | Unauthorized data access | Authorization checks |
| XXE Attacks | File disclosure, SSRF | Disable external entities |
Ruby Security Gems
| Gem | Purpose | Usage |
|---|---|---|
| bcrypt | Password hashing | has_secure_password |
| devise | Authentication framework | User authentication, session management |
| pundit | Authorization policies | Policy-based access control |
| cancancan | Authorization rules | Rule-based authorization |
| rack-attack | Rate limiting | Throttle requests, prevent brute force |
| brakeman | Static security analysis | Security vulnerability scanning |
| bundler-audit | Dependency checking | Check for vulnerable gems |
| secure_headers | Security headers | CSP, HSTS configuration |
Security Headers
| Header | Purpose | Recommended Value |
|---|---|---|
| Content-Security-Policy | Prevent XSS | default-src 'self' |
| Strict-Transport-Security | Force HTTPS | max-age=31536000; includeSubDomains |
| X-Frame-Options | Prevent clickjacking | SAMEORIGIN |
| X-Content-Type-Options | Prevent MIME sniffing | nosniff |
| X-XSS-Protection | XSS filtering | 1; mode=block |
| Referrer-Policy | Control referrer | strict-origin-when-cross-origin |
Authentication Configuration
| Setting | Recommendation | Rationale |
|---|---|---|
| Password minimum length | 12 characters | Resist brute force attacks |
| Password complexity | Upper, lower, number | Increase entropy |
| Hashing algorithm | BCrypt cost 12+ | Adaptive computational cost |
| Session timeout | 2 hours absolute | Limit exposure window |
| MFA enforcement | Required for admins | Additional authentication factor |
| Account lockout | 5 failed attempts | Prevent brute force |
| Token expiration | 15 minutes | Short-lived access tokens |
Authorization Patterns
| Pattern | Use Case | Implementation |
|---|---|---|
| Role-Based Access Control | Predefined roles | User has roles, roles have permissions |
| Attribute-Based Access Control | Complex rules | Evaluate user/resource attributes |
| Owner-Based | User owns resources | Check resource owner_id equals user_id |
| Team-Based | Shared resources | Check user belongs to resource team |
| Time-Based | Temporary access | Check access expiration timestamp |
| Location-Based | Geographic restrictions | Validate IP address or location |
Encryption Standards
| Use Case | Algorithm | Key Size |
|---|---|---|
| Password hashing | BCrypt | N/A (adaptive) |
| Data at rest | AES-GCM | 256 bits |
| Data in transit | TLS 1.3 | 2048+ bits RSA |
| Token signing | HMAC-SHA256 | 256 bits |
| Symmetric encryption | AES-CBC | 256 bits |
| Asymmetric encryption | RSA | 2048+ bits |
Input Validation Rules
| Input Type | Validation | Sanitization |
|---|---|---|
| Regex format check | Strip whitespace, lowercase | |
| URL | URI parsing | Remove javascript: protocol |
| HTML | Allowlist tags | Strip dangerous tags/attributes |
| SQL | Parameterized queries | Use ORM, escape LIKE wildcards |
| File paths | Allowlist directory | Reject path traversal |
| Numeric | Parse and range check | Convert to integer/float |
| JSON | Schema validation | Parse with safe parser |