Overview
Password security encompasses the methods and practices for protecting user credentials from unauthorized access, theft, and compromise. The core challenge stems from the need to verify user identity through passwords without storing those passwords in a form that exposes them to attackers who gain access to the database.
Modern password security relies on cryptographic hash functions that transform passwords into fixed-length strings from which the original password cannot be feasibly recovered. When a user creates an account, the system hashes their password and stores only the hash. During authentication, the system hashes the provided password and compares it to the stored hash. This approach ensures that even database administrators cannot view user passwords.
The evolution of password security reflects an ongoing arms race between protective measures and attack techniques. Early systems stored passwords in plaintext, then moved to simple hashing, then to salted hashing, and now to specialized password hashing algorithms with adjustable work factors. Each advancement addresses vulnerabilities exploited by attackers using increasingly sophisticated methods.
Password security extends beyond storage to include transmission security, validation policies, password reset mechanisms, and protection against various attack vectors including brute force attempts, rainbow tables, timing attacks, and credential stuffing.
Key Principles
Cryptographic Hashing forms the foundation of password security. A hash function takes an input of any length and produces a fixed-length output called a digest or hash. The function must be deterministic (same input always produces same output) but computationally infeasible to reverse. For a hash H and password P, H(P) produces a hash value, but computing P from H(P) should be practically impossible.
Salting prevents attackers from using precomputed tables of password hashes. A salt is a random value unique to each password that gets combined with the password before hashing. Two users with identical passwords receive different hashes because their salts differ. The salt gets stored alongside the hash in plaintext since its purpose is to add uniqueness, not secrecy.
Without salting, an attacker could create a rainbow table mapping common passwords to their hashes, then look up stolen hashes to instantly find matching passwords. With salting, each user requires a separate rainbow table computed with their specific salt, making this attack impractical.
Key Stretching increases the computational cost of hashing through iterative application of the hash function. Also called key derivation, this technique repeats the hashing operation thousands or millions of times. The legitimate system performs this calculation once per authentication attempt, causing negligible delay. An attacker attempting to crack passwords must perform the same calculation for every guess, multiplying the time required.
The work factor or iteration count determines how many rounds of hashing occur. Systems configure this value based on acceptable authentication delay and available computational resources. As hardware improves, the work factor increases to maintain adequate protection.
Algorithm Selection determines the security characteristics of password storage. General-purpose cryptographic hash functions like SHA-256 were not designed for password hashing and can be computed too quickly on modern hardware. Password-specific algorithms intentionally require significant memory and computation, making hardware acceleration difficult.
bcrypt uses the Blowfish cipher in a key-setup phase requiring substantial computation. It includes a configurable cost factor that doubles the work with each increment. scrypt adds memory requirements to frustrate GPU and ASIC-based attacks. Argon2 won the Password Hashing Competition and offers tunable memory hardness, time cost, and parallelism.
Timing Attack Resistance prevents attackers from gaining information through careful measurement of response times. String comparison operations that exit early when finding a mismatch reveal position of the first differing character. Hash comparisons must use constant-time algorithms that examine the entire hash regardless of where differences occur.
Entropy and Complexity address the raw strength of passwords themselves. Password policies attempt to increase entropy by requiring combinations of character types, minimum lengths, and prohibiting common patterns. However, strict policies often reduce security by encouraging predictable modifications like "Password1!" or requiring password changes that lead to sequential patterns.
Ruby Implementation
Ruby applications typically use the bcrypt gem for password hashing, which provides a Ruby binding to the OpenBSD bcrypt algorithm. The gem offers a clean API that handles salting and work factor configuration automatically.
require 'bcrypt'
# Creating a password hash
password = BCrypt::Password.create("user_password_123")
# => "$2a$12$K3W8J5qw2K8EUvM5Zr0Vc.X6x7R8F9G0H1I2J3K4L5M6N7O8P9Q0"
# The password object stores the full hash including algorithm, cost, and salt
password.version # => "2a"
password.cost # => 12
password.salt # => "$2a$12$K3W8J5qw2K8EUvM5Zr0Vc."
password.checksum # => "X6x7R8F9G0H1I2J3K4L5M6N7O8P9Q0"
The BCrypt::Password class wraps the hash string and provides comparison methods:
# Verifying a password
password = BCrypt::Password.new(stored_hash_from_database)
if password == "user_password_123"
# Authentication successful
end
# Alternative comparison syntax
password.is_password?("user_password_123") # => true
password.is_password?("wrong_password") # => false
The work factor controls computational cost, with each increment doubling the time required:
# Default cost factor is 12
BCrypt::Password.create("password")
# Specify custom cost (range: 4-31, higher = slower)
BCrypt::Password.create("password", cost: 14)
# Measuring cost impact
require 'benchmark'
Benchmark.bm do |bm|
bm.report("cost 10:") { BCrypt::Password.create("test", cost: 10) }
bm.report("cost 12:") { BCrypt::Password.create("test", cost: 12) }
bm.report("cost 14:") { BCrypt::Password.create("test", cost: 14) }
end
# Output shows exponential time increase:
# user system total real
# cost 10: 0.024000 0.000000 0.024000 ( 0.024736)
# cost 12: 0.096000 0.000000 0.096000 ( 0.097421)
# cost 14: 0.384000 0.004000 0.388000 ( 0.393156)
Rails provides the has_secure_password method that integrates bcrypt into ActiveRecord models:
class User < ApplicationRecord
has_secure_password
validates :password, length: { minimum: 8 }, if: -> { new_record? || !password.nil? }
end
# This adds several methods to the model:
user = User.new(email: "user@example.com", password: "secure_pass",
password_confirmation: "secure_pass")
user.save
# Authenticate method for login
user = User.find_by(email: "user@example.com")
user.authenticate("secure_pass") # => user object
user.authenticate("wrong_password") # => false
# The password field is virtual (not stored)
user.password = "new_password"
user.save # Updates password_digest column
The has_secure_password macro requires a password_digest column in the database and automatically handles:
# Migration for password digest column
class AddPasswordDigestToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :password_digest, :string
end
end
# Under the hood, has_secure_password provides:
# - password and password_confirmation virtual attributes
# - Validation of password presence on create
# - Validation that password and confirmation match
# - Length validation (72 characters maximum due to bcrypt limitation)
# - authenticate method that uses constant-time comparison
# - password_digest attribute that stores the bcrypt hash
For applications requiring custom password handling, implement the pattern manually:
class Account
attr_accessor :email
attr_reader :password_digest
def password=(new_password)
@password_digest = BCrypt::Password.create(new_password)
end
def authenticate(password_attempt)
return false unless password_digest
bcrypt_password = BCrypt::Password.new(password_digest)
bcrypt_password == password_attempt ? self : false
end
def password_reset_token
SecureRandom.urlsafe_base64(32)
end
end
account = Account.new
account.email = "user@example.com"
account.password = "my_secure_password"
# Later during login
account.authenticate("my_secure_password") # => account object
account.authenticate("wrong_guess") # => false
Security Implications
Rainbow Table Attacks exploit unsalted password hashes by using precomputed tables mapping common passwords to their hashes. An attacker with access to password hashes looks up each hash in the rainbow table to instantly find matching passwords. Rainbow tables compress vast amounts of hash data through time-memory tradeoffs.
Salting defeats rainbow tables because each salt value requires a separate table. With random per-user salts, an attacker must compute hashes for each user individually, negating the rainbow table advantage. Modern password hashing algorithms include salting as an integral component.
Brute Force and Dictionary Attacks attempt to guess passwords by systematically trying candidates. Brute force tries all possible combinations of characters within certain constraints. Dictionary attacks use lists of common passwords, words, and variations. Password complexity requirements aim to increase the search space, making these attacks infeasible within practical time frames.
The work factor in password hashing algorithms directly counters these attacks. If hashing takes 100 milliseconds, an attacker can attempt only 10 passwords per second per hash target. With proper work factors, cracking even weak passwords requires substantial time and resources.
Rate limiting at the application level provides additional protection:
class SessionsController < ApplicationController
before_action :check_rate_limit, only: [:create]
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
# Successful login
reset_rate_limit(request.ip)
session[:user_id] = user.id
redirect_to dashboard_path
else
# Failed login
record_failed_attempt(request.ip)
flash[:error] = "Invalid email or password"
render :new
end
end
private
def check_rate_limit
key = "login_attempts:#{request.ip}"
attempts = Rails.cache.read(key) || 0
if attempts >= 5
render plain: "Too many attempts. Try again later.", status: :too_many_requests
end
end
def record_failed_attempt(ip)
key = "login_attempts:#{ip}"
attempts = Rails.cache.read(key) || 0
Rails.cache.write(key, attempts + 1, expires_in: 15.minutes)
end
def reset_rate_limit(ip)
Rails.cache.delete("login_attempts:#{ip}")
end
end
Timing Attacks extract information by measuring how long operations take. In password comparison, byte-by-byte comparison that exits early when finding a mismatch reveals the position of the first incorrect character. An attacker can measure response times and systematically determine the correct password one character at a time.
Constant-time comparison functions examine the entire input regardless of where differences occur:
# Vulnerable timing attack code (DO NOT USE)
def insecure_compare(a, b)
return false if a.length != b.length
a.length.times do |i|
return false if a[i] != b[i] # Early exit reveals position
end
true
end
# Secure constant-time comparison
def secure_compare(a, b)
return false if a.nil? || b.nil? || a.length != b.length
result = 0
a.length.times do |i|
result |= a[i].ord ^ b[i].ord
end
result == 0
end
# Ruby provides ActiveSupport::SecurityUtils.secure_compare
require 'active_support/security_utils'
ActiveSupport::SecurityUtils.secure_compare(hash1, hash2)
Credential Stuffing attacks use username/password pairs leaked from breaches at other services. Attackers automate login attempts using these credentials, succeeding when users reuse passwords across sites. This attack succeeds even with proper password storage because the attacker possesses valid credentials.
Defense strategies include monitoring for unusual login patterns, implementing multi-factor authentication, and checking passwords against breach databases:
# Using haveibeenpwned API to check for compromised passwords
require 'digest'
require 'net/http'
def password_compromised?(password)
sha1 = Digest::SHA1.hexdigest(password).upcase
prefix = sha1[0..4]
suffix = sha1[5..-1]
uri = URI("https://api.pwnedpasswords.com/range/#{prefix}")
response = Net::HTTP.get(uri)
response.lines.any? { |line| line.start_with?(suffix) }
end
# Integration in model validation
class User < ApplicationRecord
validate :password_not_compromised, on: :create
private
def password_not_compromised
return unless password.present?
if password_compromised?(password)
errors.add(:password, "has appeared in a data breach and cannot be used")
end
end
end
Session Security extends password security beyond initial authentication. Session tokens must be cryptographically random, transmitted securely, and properly invalidated:
# Secure session configuration in Rails
Rails.application.config.session_store :cookie_store,
key: '_app_session',
secure: Rails.env.production?, # HTTPS only in production
httponly: true, # Prevent JavaScript access
same_site: :lax, # CSRF protection
expire_after: 24.hours
# Regenerate session ID after authentication
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
reset_session # Prevent session fixation
session[:user_id] = user.id
user.update(last_login_at: Time.current)
redirect_to dashboard_path
else
flash.now[:error] = "Invalid credentials"
render :new
end
end
def destroy
session.delete(:user_id) # Clear session data
reset_session # Clear all session values
redirect_to root_path
end
end
Password Reset Vulnerabilities create attack surfaces through predictable tokens, insufficient expiration, or token reuse. Reset mechanisms must generate cryptographically secure random tokens, set short expiration windows, and invalidate tokens after use:
class User < ApplicationRecord
def generate_password_reset_token
self.reset_token = SecureRandom.urlsafe_base64(32)
self.reset_token_sent_at = Time.current
save(validate: false)
end
def password_reset_expired?
reset_token_sent_at < 2.hours.ago
end
def reset_password(new_password)
self.password = new_password
self.reset_token = nil
self.reset_token_sent_at = nil
save
end
end
class PasswordResetsController < ApplicationController
def create
user = User.find_by(email: params[:email])
# Always show success message to prevent email enumeration
if user
user.generate_password_reset_token
PasswordResetMailer.reset_email(user).deliver_later
end
flash[:notice] = "Password reset instructions sent"
redirect_to root_path
end
def update
user = User.find_by(reset_token: params[:token])
if user&.password_reset_expired?
flash[:error] = "Password reset token has expired"
redirect_to new_password_reset_path
elsif user&.reset_password(params[:password])
flash[:success] = "Password successfully reset"
redirect_to login_path
else
flash.now[:error] = "Invalid token or password"
render :edit
end
end
end
Common Pitfalls
Storing Passwords in Plaintext represents the most critical security failure. Systems that store passwords in plaintext expose every user account if an attacker gains database access. Database backups, log files, and error reports may contain plaintext passwords, creating additional exposure points. No legitimate reason exists to store plaintext passwords.
Using General-Purpose Hash Functions like MD5, SHA-1, or SHA-256 for passwords provides insufficient protection. These algorithms compute quickly, allowing attackers to test millions or billions of password candidates per second on modern hardware. Graphics processing units and application-specific integrated circuits accelerate these hashes even further.
# Insecure password hashing (DO NOT USE)
require 'digest'
user.password_digest = Digest::SHA256.hexdigest(password)
# An attacker with access to this hash can test passwords very quickly:
# - Modern GPUs: billions of SHA-256 hashes per second
# - Dedicated hardware: even faster
# - No salt: rainbow tables provide instant lookups
Insufficient Work Factors reduce the protection provided by password hashing algorithms. bcrypt cost factors below 10 complete too quickly on modern hardware. The cost factor should balance security with acceptable authentication latency, typically requiring 100-500 milliseconds to hash a password.
Systems configured years ago need periodic work factor increases as hardware improves:
class User < ApplicationRecord
CURRENT_COST = 12
def rehash_password_if_needed(password)
bcrypt_hash = BCrypt::Password.new(password_digest)
if bcrypt_hash.cost < CURRENT_COST
self.password = password
save(validate: false)
end
end
end
# In authentication flow
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
user.rehash_password_if_needed(params[:password])
# Continue with successful login
end
Weak Password Policies fail to provide adequate protection while frustrating users. Overly complex requirements lead to predictable patterns: "Password1!", "Password2!", etc. Frequent password changes encourage users to make minor modifications or write passwords down. Length provides more security than character type requirements.
# Misguided password policy
validates :password, format: {
with: /\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}\z/,
message: "must include uppercase, lowercase, number, and special character"
}
# Better password policy
validates :password, length: { minimum: 12 }, if: :password_required?
validate :password_not_common, if: :password_required?
def password_not_common
common_passwords = %w[password 123456 qwerty abc123 password123 admin letmein]
if common_passwords.include?(password&.downcase)
errors.add(:password, "is too common")
end
end
Exposing Timing Information through non-constant-time comparisons allows attackers to extract password information. String equality operators in most languages exit immediately upon finding a mismatch, revealing the position of the first incorrect character.
# Timing attack vulnerable code
if user.password_digest == calculated_hash
# Authenticate user
end
# The == operator processes left-to-right and exits early
# An attacker measures response time to determine matching prefix
# Secure comparison
if ActiveSupport::SecurityUtils.secure_compare(
user.password_digest,
calculated_hash
)
# Authenticate user
end
Logging Sensitive Information exposes passwords when systems write credentials to log files, error reports, or debugging output. Parameter filtering must remove passwords from logs, and error handling must avoid including passwords in exception messages.
# Rails parameter filtering configuration
Rails.application.config.filter_parameters += [
:password, :password_confirmation, :current_password,
:password_digest, :reset_token, :authentication_token
]
# Custom logger that strips passwords
class SecureLogger
def self.log_authentication(user_id, success, metadata = {})
safe_metadata = metadata.except(:password, :password_confirmation)
Rails.logger.info(
"Authentication: user=#{user_id} success=#{success} #{safe_metadata}"
)
end
end
Email Enumeration allows attackers to determine valid email addresses through different responses for existing versus non-existing accounts. Password reset and registration flows should provide identical responses regardless of whether the email exists in the system.
# Vulnerable to email enumeration
def create
user = User.find_by(email: params[:email])
if user
user.send_password_reset
render json: { message: "Reset email sent" }
else
render json: { error: "Email not found" }, status: 404
end
end
# Secure against email enumeration
def create
user = User.find_by(email: params[:email])
user&.send_password_reset
# Always return success message
render json: { message: "If that email exists, reset instructions have been sent" }
end
Bcrypt 72-Byte Limitation truncates passwords longer than 72 bytes, potentially weakening very long passwords. The bcrypt algorithm processes only the first 72 bytes of input, silently ignoring additional characters. Applications accepting passwords longer than 72 characters should pre-hash them:
# Handling passwords longer than 72 bytes
def set_password(password)
# Pre-hash long passwords before bcrypt
if password.bytesize > 72
password = Digest::SHA256.hexdigest(password)
end
self.password_digest = BCrypt::Password.create(password)
end
Practical Examples
User Registration with Secure Password Storage
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 12, maximum: 128 }, if: :password_required?
validate :password_complexity, if: :password_required?
private
def password_required?
new_record? || password.present?
end
def password_complexity
return if password.blank?
# Entropy-based check instead of rigid character requirements
character_sets = 0
character_sets += 1 if password =~ /[a-z]/
character_sets += 1 if password =~ /[A-Z]/
character_sets += 1 if password =~ /[0-9]/
character_sets += 1 if password =~ /[^A-Za-z0-9]/
if character_sets < 2
errors.add(:password, "must use at least 2 different character types")
end
end
end
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
session[:user_id] = @user.id
AuditLog.record_event('user_registered', user_id: @user.id, ip: request.ip)
redirect_to dashboard_path, notice: "Account created successfully"
else
flash.now[:error] = "Please correct the errors below"
render :new
end
end
private
def user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
end
Authentication with Rate Limiting and Audit Logging
class AuthenticationService
MAX_ATTEMPTS = 5
LOCKOUT_DURATION = 15.minutes
def self.authenticate(email, password, request_ip)
return failure_response("Account temporarily locked") if account_locked?(email)
user = User.find_by(email: email)
if user&.authenticate(password)
handle_successful_login(user, request_ip)
else
handle_failed_login(email, request_ip)
end
end
private
def self.handle_successful_login(user, ip)
clear_failed_attempts(user.email)
log_authentication_event(user.id, true, ip)
user.update_columns(
last_login_at: Time.current,
last_login_ip: ip,
login_count: user.login_count + 1
)
{ success: true, user: user }
end
def self.handle_failed_login(email, ip)
increment_failed_attempts(email)
log_authentication_event(nil, false, ip, email: email)
{ success: false, message: "Invalid email or password" }
end
def self.account_locked?(email)
key = "login_attempts:#{email}"
attempts = Rails.cache.read(key) || 0
attempts >= MAX_ATTEMPTS
end
def self.increment_failed_attempts(email)
key = "login_attempts:#{email}"
attempts = Rails.cache.read(key) || 0
Rails.cache.write(key, attempts + 1, expires_in: LOCKOUT_DURATION)
end
def self.clear_failed_attempts(email)
Rails.cache.delete("login_attempts:#{email}")
end
def self.log_authentication_event(user_id, success, ip, metadata = {})
AuthenticationLog.create(
user_id: user_id,
success: success,
ip_address: ip,
attempted_email: metadata[:email],
user_agent: metadata[:user_agent],
timestamp: Time.current
)
end
end
# Usage in controller
class SessionsController < ApplicationController
def create
result = AuthenticationService.authenticate(
params[:email],
params[:password],
request.ip
)
if result[:success]
session[:user_id] = result[:user].id
redirect_to dashboard_path
else
flash.now[:error] = result[:message]
render :new
end
end
end
Password Reset with Secure Token Handling
class PasswordResetService
TOKEN_EXPIRATION = 2.hours
def self.initiate_reset(email)
user = User.find_by(email: email)
if user
token = generate_token
store_reset_token(user, token)
send_reset_email(user, token)
end
# Always return success to prevent email enumeration
{ success: true, message: "Password reset instructions sent" }
end
def self.complete_reset(token, new_password)
user = find_user_by_token(token)
return { success: false, message: "Invalid or expired token" } unless user
return { success: false, message: "Token expired" } if token_expired?(user)
if user.update(password: new_password, reset_token: nil, reset_sent_at: nil)
invalidate_sessions(user)
notify_password_change(user)
{ success: true, message: "Password successfully reset" }
else
{ success: false, errors: user.errors.full_messages }
end
end
private
def self.generate_token
SecureRandom.urlsafe_base64(32)
end
def self.store_reset_token(user, token)
# Hash the token before storing
hashed_token = Digest::SHA256.hexdigest(token)
user.update_columns(
reset_token: hashed_token,
reset_sent_at: Time.current
)
end
def self.find_user_by_token(token)
hashed_token = Digest::SHA256.hexdigest(token)
User.find_by(reset_token: hashed_token)
end
def self.token_expired?(user)
user.reset_sent_at < TOKEN_EXPIRATION.ago
end
def self.invalidate_sessions(user)
# Increment a version column to invalidate existing sessions
user.increment!(:session_version)
end
def self.send_reset_email(user, token)
PasswordResetMailer.reset_instructions(user, token).deliver_later
end
def self.notify_password_change(user)
PasswordResetMailer.password_changed(user).deliver_later
end
end
Multi-Factor Authentication Integration
class TwoFactorAuthenticationService
def self.setup(user)
secret = ROTP::Base32.random
totp = ROTP::TOTP.new(secret, issuer: "YourApp")
user.update(
two_factor_secret: encrypt_secret(secret),
two_factor_enabled: false
)
{
secret: secret,
qr_code: totp.provisioning_uri(user.email),
backup_codes: generate_backup_codes(user)
}
end
def self.verify_and_enable(user, code)
totp = ROTP::TOTP.new(decrypt_secret(user.two_factor_secret))
if totp.verify(code, drift_behind: 15, drift_ahead: 15)
user.update(two_factor_enabled: true)
true
else
false
end
end
def self.verify_code(user, code)
return verify_backup_code(user, code) if code.length > 6
totp = ROTP::TOTP.new(decrypt_secret(user.two_factor_secret))
totp.verify(code, drift_behind: 15, drift_ahead: 15)
end
private
def self.generate_backup_codes(user)
codes = 10.times.map { SecureRandom.hex(4) }
hashed_codes = codes.map { |code| BCrypt::Password.create(code) }
user.update(backup_codes: hashed_codes)
codes
end
def self.verify_backup_code(user, code)
user.backup_codes.each_with_index do |hashed_code, index|
bcrypt_code = BCrypt::Password.new(hashed_code)
if bcrypt_code == code
user.backup_codes.delete_at(index)
user.save
return true
end
end
false
end
def self.encrypt_secret(secret)
# Use Rails encrypted credentials or similar
cipher = OpenSSL::Cipher.new('aes-256-gcm')
cipher.encrypt
cipher.key = Rails.application.credentials.secret_key_base[0..31]
iv = cipher.random_iv
encrypted = cipher.update(secret) + cipher.final
auth_tag = cipher.auth_tag
Base64.strict_encode64("#{iv}#{auth_tag}#{encrypted}")
end
def self.decrypt_secret(encrypted_secret)
decoded = Base64.strict_decode64(encrypted_secret)
iv = decoded[0..11]
auth_tag = decoded[12..27]
encrypted = decoded[28..-1]
decipher = OpenSSL::Cipher.new('aes-256-gcm')
decipher.decrypt
decipher.key = Rails.application.credentials.secret_key_base[0..31]
decipher.iv = iv
decipher.auth_tag = auth_tag
decipher.update(encrypted) + decipher.final
end
end
Testing Approaches
Unit Testing Password Storage and Verification
require 'rails_helper'
RSpec.describe User, type: :model do
describe 'password hashing' do
it 'hashes password on save' do
user = User.create(email: 'test@example.com', password: 'secure_password')
expect(user.password_digest).to be_present
expect(user.password_digest).not_to eq('secure_password')
expect(user.password_digest).to start_with('$2a$')
end
it 'does not store plaintext password' do
user = User.create(email: 'test@example.com', password: 'secure_password')
expect(user.password).to be_nil
expect(user.attributes).not_to have_key('password')
end
it 'authenticates with correct password' do
user = User.create(email: 'test@example.com', password: 'secure_password')
expect(user.authenticate('secure_password')).to eq(user)
end
it 'rejects incorrect password' do
user = User.create(email: 'test@example.com', password: 'secure_password')
expect(user.authenticate('wrong_password')).to be_falsey
end
it 'uses appropriate work factor' do
user = User.create(email: 'test@example.com', password: 'secure_password')
bcrypt_hash = BCrypt::Password.new(user.password_digest)
expect(bcrypt_hash.cost).to be >= 12
end
end
describe 'password validation' do
it 'requires minimum length' do
user = User.new(email: 'test@example.com', password: 'short')
expect(user).not_to be_valid
expect(user.errors[:password]).to include('is too short (minimum is 12 characters)')
end
it 'accepts passwords at minimum length' do
user = User.new(email: 'test@example.com', password: 'twelve_chars')
expect(user).to be_valid
end
it 'handles special characters correctly' do
password = "p@ssw0rd!#$%^&*()"
user = User.create(email: 'test@example.com', password: password)
expect(user.authenticate(password)).to eq(user)
end
it 'handles unicode characters' do
password = "пароль密码🔐"
user = User.create(email: 'test@example.com', password: password)
expect(user.authenticate(password)).to eq(user)
end
end
end
Integration Testing Authentication Flow
require 'rails_helper'
RSpec.describe 'User Authentication', type: :request do
let(:user) { User.create(email: 'user@example.com', password: 'secure_password') }
describe 'POST /login' do
it 'authenticates with valid credentials' do
post '/login', params: { email: user.email, password: 'secure_password' }
expect(response).to redirect_to(dashboard_path)
expect(session[:user_id]).to eq(user.id)
end
it 'rejects invalid password' do
post '/login', params: { email: user.email, password: 'wrong_password' }
expect(response).to have_http_status(:unprocessable_entity)
expect(session[:user_id]).to be_nil
end
it 'handles non-existent email' do
post '/login', params: { email: 'nonexistent@example.com', password: 'password' }
expect(response).to have_http_status(:unprocessable_entity)
expect(session[:user_id]).to be_nil
end
it 'rate limits failed attempts' do
6.times do
post '/login', params: { email: user.email, password: 'wrong_password' }
end
expect(response).to have_http_status(:too_many_requests)
end
end
describe 'timing attack resistance' do
it 'takes similar time for valid and invalid emails' do
valid_time = Benchmark.realtime do
post '/login', params: { email: user.email, password: 'wrong' }
end
invalid_time = Benchmark.realtime do
post '/login', params: { email: 'nonexistent@example.com', password: 'wrong' }
end
# Times should be within 50ms of each other
expect((valid_time - invalid_time).abs).to be < 0.05
end
end
end
Testing Password Reset Functionality
require 'rails_helper'
RSpec.describe 'Password Reset', type: :request do
let(:user) { User.create(email: 'user@example.com', password: 'old_password') }
describe 'POST /password_resets' do
it 'generates reset token for valid email' do
post '/password_resets', params: { email: user.email }
user.reload
expect(user.reset_token).to be_present
expect(user.reset_sent_at).to be_within(1.second).of(Time.current)
end
it 'does not reveal whether email exists' do
valid_response = post '/password_resets', params: { email: user.email }
invalid_response = post '/password_resets', params: { email: 'nonexistent@example.com' }
expect(response).to have_http_status(:ok)
expect(flash[:notice]).to include('instructions sent')
end
it 'sends reset email' do
expect {
post '/password_resets', params: { email: user.email }
}.to change { ActionMailer::Base.deliveries.count }.by(1)
end
end
describe 'PATCH /password_resets/:token' do
before do
user.generate_password_reset_token
end
it 'resets password with valid token' do
patch "/password_resets/#{user.reset_token}",
params: { password: 'new_secure_password' }
user.reload
expect(user.authenticate('new_secure_password')).to eq(user)
expect(user.reset_token).to be_nil
end
it 'rejects expired token' do
user.update(reset_sent_at: 3.hours.ago)
patch "/password_resets/#{user.reset_token}",
params: { password: 'new_secure_password' }
expect(response).to have_http_status(:unprocessable_entity)
expect(user.reload.authenticate('old_password')).to eq(user)
end
it 'invalidates token after use' do
token = user.reset_token
patch "/password_resets/#{token}",
params: { password: 'new_secure_password' }
patch "/password_resets/#{token}",
params: { password: 'another_password' }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
Security Testing for Common Vulnerabilities
require 'rails_helper'
RSpec.describe 'Password Security', type: :request do
describe 'SQL injection protection' do
it 'handles malicious email input safely' do
malicious_email = "'; DROP TABLE users; --"
expect {
post '/login', params: { email: malicious_email, password: 'password' }
}.not_to change { User.count }
end
end
describe 'constant-time comparison' do
let(:user) { User.create(email: 'user@example.com', password: 'secure_password') }
it 'uses secure comparison for password verification' do
# Verify BCrypt::Password uses constant-time comparison
allow_any_instance_of(BCrypt::Password).to receive(:==).and_call_original
user.authenticate('wrong_password')
expect_any_instance_of(BCrypt::Password).to have_received(:==)
end
end
describe 'session security' do
it 'regenerates session ID on login' do
user = User.create(email: 'user@example.com', password: 'password')
get '/login'
old_session_id = session.id.to_s
post '/login', params: { email: user.email, password: 'password' }
new_session_id = session.id.to_s
expect(new_session_id).not_to eq(old_session_id)
end
it 'sets secure session cookies in production' do
allow(Rails.env).to receive(:production?).and_return(true)
user = User.create(email: 'user@example.com', password: 'password')
post '/login', params: { email: user.email, password: 'password' }
cookie = response.cookies['_app_session']
expect(cookie).to include('secure')
expect(cookie).to include('httponly')
end
end
end
Reference
Password Hashing Algorithm Comparison
| Algorithm | Work Factor | Memory Hard | Salt | Recommended |
|---|---|---|---|---|
| bcrypt | Cost parameter (4-31) | No | Yes | Yes |
| scrypt | N, r, p parameters | Yes | Yes | Yes |
| Argon2 | Time, memory, parallelism | Yes | Yes | Yes (preferred) |
| PBKDF2 | Iteration count | No | Yes | Acceptable |
| MD5 | None | No | Manual | No |
| SHA-256 | None | No | Manual | No |
BCrypt Cost Factor Guidelines
| Cost | Hashing Time | Use Case |
|---|---|---|
| 10 | ~25ms | Minimum acceptable |
| 12 | ~100ms | Default recommended |
| 14 | ~400ms | High security applications |
| 16 | ~1.6s | Maximum practical for web |
| 18+ | 6s+ | Offline storage only |
Ruby Password Security Methods
| Method | Purpose | Usage |
|---|---|---|
| BCrypt::Password.create | Hash a password | BCrypt::Password.create(password, cost: 12) |
| BCrypt::Password.new | Load existing hash | BCrypt::Password.new(stored_hash) |
| password == string | Verify password | bcrypt_password == user_input |
| has_secure_password | Rails authentication | Include in ActiveRecord model |
| authenticate | Verify and return user | user.authenticate(password) |
| SecureRandom.urlsafe_base64 | Generate tokens | SecureRandom.urlsafe_base64(32) |
| ActiveSupport::SecurityUtils.secure_compare | Constant-time comparison | secure_compare(hash1, hash2) |
Session Configuration Parameters
| Parameter | Values | Purpose |
|---|---|---|
| secure | true, false | Require HTTPS for cookie transmission |
| httponly | true, false | Prevent JavaScript access to cookie |
| same_site | :strict, :lax, :none | CSRF protection level |
| expire_after | Duration | Session expiration time |
| key | String | Cookie name |
Password Validation Patterns
| Requirement | Ruby Pattern | Description |
|---|---|---|
| Minimum length | length: { minimum: 12 } | Enforce minimum password length |
| Maximum length | length: { maximum: 128 } | Prevent bcrypt issues and DoS |
| Lowercase | password =~ /[a-z]/ | Contains lowercase letter |
| Uppercase | password =~ /[A-Z]/ | Contains uppercase letter |
| Digit | password =~ /[0-9]/ | Contains number |
| Special char | password =~ /[^A-Za-z0-9]/ | Contains special character |
Common Attack Vectors and Mitigations
| Attack Type | Mitigation Strategy | Implementation |
|---|---|---|
| Rainbow Tables | Salt every password | Use bcrypt (automatic salting) |
| Brute Force | High work factor + rate limiting | Cost 12+ and IP-based limits |
| Dictionary Attack | Work factor + breach detection | Check haveibeenpwned API |
| Timing Attack | Constant-time comparison | ActiveSupport::SecurityUtils |
| Credential Stuffing | MFA + anomaly detection | TOTP or SMS verification |
| Session Fixation | Regenerate session ID | reset_session on login |
| Token Prediction | Cryptographic randomness | SecureRandom.urlsafe_base64 |
Password Reset Security Checklist
| Security Control | Implementation |
|---|---|
| Cryptographically random token | SecureRandom.urlsafe_base64(32) |
| Token expiration | 2-4 hours maximum |
| Single-use tokens | Clear token after successful reset |
| Hash stored tokens | Digest::SHA256.hexdigest(token) |
| No email enumeration | Always show success message |
| HTTPS only | Enforce SSL for reset links |
| Notify on password change | Send confirmation email |
| Invalidate existing sessions | Increment session version |
Security Headers for Authentication
| Header | Value | Purpose |
|---|---|---|
| Strict-Transport-Security | max-age=31536000 | Enforce HTTPS |
| X-Content-Type-Options | nosniff | Prevent MIME sniffing |
| X-Frame-Options | DENY | Prevent clickjacking |
| Content-Security-Policy | default-src 'self' | Restrict resource loading |
| X-XSS-Protection | 1; mode=block | Enable XSS filtering |