Skip to content

R8 A2 Authentication Improvements

Ken Johnson edited this page Dec 5, 2025 · 1 revision

A2 - Rails 8 Authentication Improvements

Rails 8 introduces a new authentication generator that makes it significantly easier to implement secure authentication out-of-the-box.

What's New in Rails 8

The Authentication Generator

rails generate authentication

This single command creates:

  • ✅ User model with has_secure_password
  • ✅ Sessions controller with secure credential handling
  • ✅ Password reset functionality
  • ✅ BCrypt password hashing by default
  • ✅ Secure password recovery via email
  • ✅ Session management
  • ✅ Rate limiting for login attempts

Rails 5 vs Rails 8: Authentication Comparison

Rails 5: Manual Implementation (RailsGoat Example)

RailsGoat's Vulnerable Authentication:

# app/models/user.rb (Rails 5 - VULNERABLE)
class User < ApplicationRecord
  before_save :hash_password

  def self.authenticate(email, password)
    auth = nil
    user = find_by_email(email)
    raise "#{email} doesn't exist!" if !(user)  # ❌ Reveals email

    if user.password == Digest::MD5.hexdigest(password)  # ❌ MD5, no salt
      auth = user
    else
      raise "Incorrect Password!"  # ❌ Different error
    end
    return auth
  end

  def hash_password
    if self.password.present?
      self.password = Digest::MD5.hexdigest(password)  # ❌ Weak hashing
    end
  end
end

Problems:

  1. ❌ MD5 hashing (broken, vulnerable to rainbow tables)
  2. ❌ No salt (same password = same hash)
  3. ❌ Credential enumeration (different errors for invalid email vs password)
  4. ❌ No rate limiting (brute force possible)
  5. ❌ Manual implementation (error-prone)

Rails 8: Generator-Based Implementation

What the Generator Creates:

rails generate authentication

# Creates:
#   app/models/user.rb
#   app/controllers/sessions_controller.rb
#   app/controllers/passwords_controller.rb
#   app/views/sessions/new.html.erb
#   app/views/passwords/new.html.erb
#   app/views/passwords/edit.html.erb
#   test/controllers/sessions_controller_test.rb
#   ... and more

Generated User Model (Rails 8):

# app/models/user.rb (Rails 8 - SECURE)
class User < ApplicationRecord
  has_secure_password  # ✅ BCrypt with automatic salt

  validates :email_address,
    presence: true,
    uniqueness: true

  normalizes :email_address,
    with: ->(e) { e.strip.downcase }
end

Generated Sessions Controller (Rails 8):

# app/controllers/sessions_controller.rb (Rails 8 - SECURE)
class SessionsController < ApplicationController
  def create
    # ✅ authenticate_by: no timing attacks, single generic error
    if user = User.authenticate_by(
      email_address: params[:email_address],
      password: params[:password]
    )
      session[:user_id] = user.id
      redirect_to root_path, notice: "Signed in successfully"
    else
      # ✅ Generic error message (no credential enumeration)
      flash.now[:alert] = "Invalid email or password"
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    session[:user_id] = nil
    redirect_to root_path, notice: "Signed out successfully"
  end
end

Benefits:

  1. ✅ BCrypt hashing (industry standard, slow by design)
  2. ✅ Automatic per-user salt
  3. ✅ Generic error messages (no credential enumeration)
  4. authenticate_by method (constant-time comparison, no timing attacks)
  5. ✅ Generated tests included
  6. ✅ Password reset functionality included

The authenticate_by Method

Rails 8 introduces authenticate_by as a secure alternative to manual authentication:

Old Way (Rails 5 - VULNERABLE)

user = User.find_by(email: params[:email])
if user && user.authenticate(params[:password])
  # Login success
else
  # Login failed
end

Problems:

  • Timing attack: find_by returns nil faster if email doesn't exist
  • Two-step process reveals whether email exists
  • Developers often write different error messages

New Way (Rails 8 - SECURE)

user = User.authenticate_by(
  email_address: params[:email],
  password: params[:password]
)

if user
  # Login success
else
  # Login failed - generic error
end

Benefits:

  • ✅ Single method call
  • ✅ Constant-time comparison (no timing attacks)
  • ✅ Returns nil for both invalid email and invalid password
  • ✅ Encourages generic error messages

has_secure_password: Then and Now

The has_secure_password method has been available since Rails 3.1, but Rails 8's generator makes it the default.

What has_secure_password Provides

class User < ApplicationRecord
  has_secure_password
end

Automatic Features:

  1. ✅ BCrypt password hashing (secure by design)
  2. ✅ Automatic salt generation (unique per user)
  3. password and password_confirmation virtual attributes
  4. authenticate(password) method
  5. ✅ Validations for password presence and confirmation
  6. ✅ Stores hash in password_digest column

Database Migration:

create_table :users do |t|
  t.string :email_address, null: false, index: { unique: true }
  t.string :password_digest, null: false  # ← BCrypt hash stored here

  t.timestamps
end

BCrypt vs MD5

Feature MD5 (RailsGoat) BCrypt (Rails 8)
Speed Very fast (bad!) Intentionally slow (good!)
Salt None Automatic per-user
Security Broken Secure
Rainbow Tables Vulnerable Protected
Cost Factor N/A Configurable (default: 12)
Modern Use Never use Industry standard

BCrypt Speed Comparison:

require 'benchmark'
require 'digest'
require 'bcrypt'

password = "SecureP@ssw0rd123"

Benchmark.bm do |x|
  x.report("MD5 (vulnerable):") do
    10_000.times { Digest::MD5.hexdigest(password) }
  end

  x.report("BCrypt (secure):  ") do
    10.times { BCrypt::Password.create(password) }
  end
end

# Results (approximate):
# MD5 (vulnerable):  0.002 seconds for 10,000 hashes (5,000,000/sec)
# BCrypt (secure):   0.500 seconds for 10 hashes (20/sec)
#
# BCrypt is ~250,000x slower - this is intentional!
# Makes brute-force attacks impractical

Password Reset Functionality

Rails 8 generator includes secure password reset:

Generated Password Reset Flow

  1. User requests password reset
  2. System generates secure random token
  3. Token stored in database with expiration
  4. Email sent with reset link
  5. User clicks link, provides new password
  6. Token validated and password updated
  7. Token invalidated after use

Generated Passwords Controller

# app/controllers/passwords_controller.rb (Rails 8)
class PasswordsController < ApplicationController
  def create
    user = User.find_by(email_address: params[:email_address])

    if user
      # ✅ Always generate token (prevent timing attacks)
      user.generate_password_reset_token
      PasswordMailer.with(user: user).reset.deliver_later
    end

    # ✅ Generic message (don't reveal if email exists)
    redirect_to root_path,
      notice: "If your email is in our system, you'll receive reset instructions"
  end

  def update
    user = User.find_by_password_reset_token(params[:token])

    if user && user.password_reset_token_valid?
      user.update(password: params[:password])
      redirect_to login_path, notice: "Password reset successfully"
    else
      redirect_to root_path, alert: "Invalid or expired reset link"
    end
  end
end

Security Features:

  • ✅ Generic confirmation message (no email enumeration)
  • ✅ Secure random token generation
  • ✅ Token expiration (typically 1 hour)
  • ✅ One-time use tokens
  • ✅ Email delivery for all attempts (rate limiting recommended)

RailsGoat vs Rails 8 Generator

Credential Enumeration Comparison

RailsGoat (Rails 5 - VULNERABLE):

# Different error messages reveal information
begin
  user = User.authenticate(params[:email], params[:password])
rescue Exception => e
  flash[:error] = e.message  # ❌ Shows "email doesn't exist" or "Incorrect Password"
end

Attack:

# Test if email exists
curl -X POST http://localhost:3000/login \
  -d "[email protected]&password=wrong"

# Response: "[email protected] doesn't exist!" ← Email is invalid
# Response: "Incorrect Password!" ← Email is valid

Rails 8 Generator (SECURE):

user = User.authenticate_by(email_address: params[:email], password: params[:password])

if user
  # Success
else
  flash[:alert] = "Invalid email or password"  # ✅ Generic message
end

Same Attack:

curl -X POST http://localhost:3000/login \
  -d "[email protected]&password=wrong"

# Always: "Invalid email or password" ← No information revealed

Implementing Rails 8 Auth in RailsGoat

If you wanted to demonstrate the secure way (for comparison):

Step 1: Run Generator

cd /path/to/railsgoat
rails generate authentication

Step 2: Add BCrypt Gem

# Gemfile
gem 'bcrypt', '~> 3.1.7'
bundle install

Step 3: Migration

rails generate migration AddPasswordDigestToUsers password_digest:string
rails db:migrate

Step 4: Compare Controllers

Create a secure login controller alongside the vulnerable one:

# app/controllers/secure_sessions_controller.rb
class SecureSessionsController < ApplicationController
  protect_from_forgery with: :exception  # ✅ CSRF enabled

  def create
    # ✅ Secure authentication
    user = User.authenticate_by(
      email_address: params[:email],
      password: params[:password]
    )

    if user
      session[:user_id] = user.id
      redirect_to home_dashboard_index_path
    else
      flash[:error] = "Invalid email or password"  # ✅ Generic
      render :new
    end
  end
end

This allows you to demonstrate:

  • ❌ Vulnerable auth (existing SessionsController)
  • ✅ Secure auth (new SecureSessionsController)

Testing Authentication Security

Test 1: Credential Enumeration

# test/controllers/sessions_controller_test.rb
test "should not reveal whether email exists" do
  # Invalid email
  post sessions_url, params: {
    email: "[email protected]",
    password: "password"
  }
  invalid_email_message = flash[:error]

  # Valid email, wrong password
  user = users(:one)
  post sessions_url, params: {
    email: user.email,
    password: "wrong"
  }
  wrong_password_message = flash[:error]

  # ✅ Messages should be identical
  assert_equal invalid_email_message, wrong_password_message
end

Test 2: Password Hashing

# test/models/user_test.rb
test "password should be hashed with BCrypt" do
  user = User.create(
    email: "[email protected]",
    password: "SecurePassword123",
    password_confirmation: "SecurePassword123"
  )

  # ✅ Should not store plain text
  assert_not_equal "SecurePassword123", user.password_digest

  # ✅ Should use BCrypt format
  assert user.password_digest.start_with?("$2a$")

  # ✅ Should authenticate correctly
  assert user.authenticate("SecurePassword123")
  assert_not user.authenticate("WrongPassword")
end

Test 3: Timing Attack Prevention

# test/models/user_test.rb
test "authenticate_by should have constant time" do
  require 'benchmark'

  user = users(:one)

  # Time for invalid email
  time_invalid_email = Benchmark.realtime do
    100.times do
      User.authenticate_by(
        email: "[email protected]",
        password: "password"
      )
    end
  end

  # Time for valid email, wrong password
  time_wrong_password = Benchmark.realtime do
    100.times do
      User.authenticate_by(
        email: user.email,
        password: "wrongpassword"
      )
    end
  end

  # ✅ Times should be similar (within 20%)
  ratio = time_invalid_email / time_wrong_password
  assert ratio > 0.8 && ratio < 1.2,
    "Timing difference reveals information: #{ratio}"
end

Key Takeaways

  1. Rails 8 makes secure auth easy with the authentication generator
  2. BCrypt is the standard - never use MD5 or SHA-1 for passwords
  3. Generic error messages prevent credential enumeration
  4. authenticate_by provides constant-time comparison
  5. has_secure_password handles hashing automatically
  6. Generated tests ensure security from the start
  7. RailsGoat's vulnerabilities persist when using custom auth

Bottom Line: Rails 8 doesn't prevent authentication vulnerabilities when developers write custom authentication logic (like RailsGoat does), but it makes implementing secure authentication much easier for production applications.

Additional Resources

Sections are divided by their OWASP Top Ten label (A1-A10) and marked as R4 and R5 for Rails 4 and 5.

Clone this wiki locally