CrackedRuby CrackedRuby

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