Rails 8 Authentication Generator: Complete Migration from Devise

Devise does 100 things. Rails 8’s authentication generator does 8 things. That’s not a limitation – it’s the point.
We’ve migrated three client apps off Devise in the past year. Every time, the codebase got smaller, the auth bugs disappeared, and nobody missed the 80% of Devise features they never used. The pattern is consistent: teams carry Devise’s full weight – 10+ modules, 200+ lines of initializer config, Warden middleware – to get login, logout, and password reset. Rails 8 gives you exactly that, in code you can read in five minutes.
This post covers the real migration path: what’s compatible (passwords transfer directly), what breaks (remember me, lockable, trackable need custom code), and the phased rollout strategy we use to avoid locking out production users.
The Problem with Devise in Modern Rails Applications #
Devise has been the default authentication choice for Rails since 2009. It’s not bad software – it’s over-engineered software for most apps. Here’s what we keep finding in client codebases.
Complexity and Cognitive Overhead #
Devise provides 10+ authentication modules, each with its own configuration, customization requirements, and edge cases:
# config/initializers/devise.rb - Typical Devise configuration
Devise.setup do |config|
config.mailer_sender = 'please-change-me@config.com'
config.case_insensitive_keys = [:email]
config.strip_whitespace_keys = [:email]
config.skip_session_storage = [:http_auth]
config.stretches = Rails.env.test? ? 1 : 12
config.reconfirmable = true
config.expire_all_remember_me_on_sign_out = true
config.password_length = 6..128
config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
config.reset_password_within = 6.hours
config.sign_out_via = :delete
config.omniauth :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET']
# ... 50+ more configuration options
end
This 200+ line configuration file requires deep Devise knowledge to maintain safely. Most applications use only 20% of Devise’s features yet carry 100% of its complexity.
Hidden Behaviors and Magic #
Devise introduces dozens of controller filters and helpers that operate invisibly:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user! # What does this actually do?
end
Behind this single line:
- Multiple database queries checking session validity
- Cookie parsing and validation
- Warden strategy execution
- Session token verification
- Password timeout checks (if configured)
- Remember me functionality (if enabled)
- Two-factor authentication verification (if added)
Understanding and debugging these invisible operations requires deep Devise internals knowledge.
Upgrade Challenges #
Devise’s complexity makes upgrades risky. Real-world example from a client migration:
# Rails 6 → Rails 7 upgrade broke Devise
# Error: undefined method `persisted?' for nil:NilClass
# Root cause: Devise's Warden integration conflicted with Rails 7's
# session handling changes
# Required fixes:
# 1. Update Devise gem
# 2. Update Warden gem
# 3. Update Omniauth gems
# 4. Regenerate Devise configuration
# 5. Test all authentication flows
# 6. Update custom Devise modules
# 7. Migrate encrypted passwords (algorithm changes)
On a client project, this upgrade required significant development and testing time for what should have been a simple Rails version upgrade.
Security Through Obscurity #
Devise’s complexity can obscure security vulnerabilities:
# Real vulnerability found in production application
# devise.rb configuration
config.password_length = 6..128 # Too short!
config.stretches = 1 # Development setting in production!
config.expire_all_remember_me_on_sign_out = false # Security risk!
On a client project, these misconfigurations went undetected until a security audit because they were buried in a 300-line initializer that no one fully understood.
Performance Overhead #
Devise’s flexibility comes with runtime costs. A typical Devise authenticate_user! call executes 4-6 database queries per request (session lookup, token validation, trackable updates, etc.), while Rails 8’s built-in authentication executes 1-2 queries. Rails 8 auth is simpler and has fewer database queries per request, but we have not published formal benchmarks comparing throughput.
For teams struggling with Devise complexity and seeking to modernize their authentication stack, our technical leadership consulting helps evaluate whether Rails 8’s built-in authentication meets your specific security requirements and business needs.
Understanding Rails 8’s Built-In Authentication #
Rails 8 auth asks one question: what do you actually use? For most apps, the answer is surprisingly little. If you haven’t read the overview of Rails 8’s authentication generator , start there for the high-level picture.
Core Philosophy: Convention Over Framework #
Rails 8 authentication follows “convention over configuration” principles:
# Generate complete authentication system
$ rails generate authentication
# This creates:
# - User model with has_secure_password
# - Session model for session tracking
# - Sessions controller for login/logout
# - Authentication concern for controllers
# - Migrations for users and sessions tables
That’s it. No complex configuration files, no mysterious modules, no hidden behaviors. The foundation builds on authentication helpers introduced in Rails 7.1
– generates_token_for, authenticate_by, and normalizes – so the patterns will feel familiar if you’ve already adopted those.
Architecture: Simple and Transparent #
Database Schema #
# db/migrate/[timestamp]_create_users.rb
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
Clean, minimal, and explicit. No polymorphic associations, no STI, no unnecessary columns.
User Model #
# 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 }, if: :password_digest_changed?
normalizes :email, with: -> email { email.strip.downcase }
generates_token_for :password_reset, expires_in: 15.minutes do
password_digest&.last(10)
end
generates_token_for :email_confirmation, expires_in: 24.hours do
email
end
end
What has_secure_password provides #
- BCrypt password hashing with appropriate cost factor (and if you want Argon2id instead, see our Argon2 migration guide )
passwordandpassword_confirmationvirtual attributesauthenticate(password)method for password verification- Automatic password digest generation
- Password validation (presence, length, confirmation)
Sessions Controller #
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
redirect_to root_path, notice: "Signed in successfully"
else
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
def destroy
session.delete(:user_id)
redirect_to root_path, notice: "Signed out successfully"
end
end
Transparent, understandable, and easy to customize. No hidden behaviors.
Current User Pattern #
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
private
def current_user
@current_user ||= session[:user_id] && User.find_by(id: session[:user_id])
end
helper_method :current_user
def user_signed_in?
current_user.present?
end
helper_method :user_signed_in?
def authenticate_user!
redirect_to new_session_path, alert: "Please sign in" unless user_signed_in?
end
end
Simple, explicit, and fully under your control.
Security Features Built-In #
Password Reset with Secure Tokens #
# app/controllers/passwords_controller.rb
class PasswordsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:email])
if user
# Generate secure token using Rails 7.1+ generates_token_for
token = user.generate_token_for(:password_reset)
# Send password reset email
UserMailer.password_reset(user, token).deliver_later
redirect_to root_path, notice: "Password reset instructions sent"
else
flash.now[:alert] = "Email not found"
render :new, status: :unprocessable_entity
end
end
def edit
@user = User.find_by_token_for(:password_reset, params[:token])
unless @user
redirect_to new_password_path, alert: "Invalid or expired password reset link"
end
end
def update
@user = User.find_by_token_for(:password_reset, params[:token])
if @user&.update(password_params)
session[:user_id] = @user.id
redirect_to root_path, notice: "Password updated successfully"
else
flash.now[:alert] = "Could not update password"
render :edit, status: :unprocessable_entity
end
end
private
def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end
Email Confirmation #
# app/controllers/email_confirmations_controller.rb
class EmailConfirmationsController < ApplicationController
def new
@user = User.find_by_token_for(:email_confirmation, params[:token])
if @user&.update(confirmed_at: Time.current)
session[:user_id] = @user.id
redirect_to root_path, notice: "Email confirmed successfully"
else
redirect_to root_path, alert: "Invalid confirmation link"
end
end
end
Rate Limiting and Brute Force Protection #
# config/initializers/rack_attack.rb
class Rack::Attack
# Throttle login attempts by email
throttle("logins/email", limit: 5, period: 60.seconds) do |req|
if req.path == "/session" && req.post?
req.params['email'].to_s.downcase.gsub(/\s+/, "")
end
end
# Throttle password reset requests
throttle("password_resets/ip", limit: 3, period: 60.seconds) do |req|
req.ip if req.path == "/passwords" && req.post?
end
end
# config/application.rb
config.middleware.use Rack::Attack
Extensibility: Build What You Need #
Rails 8 authentication provides a foundation for adding advanced features:
Two-Factor Authentication (TOTP) #
# Gemfile
gem 'rotp' # Ruby One Time Password library
# db/migrate/[timestamp]_add_otp_to_users.rb
class AddOtpToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :otp_secret, :string
add_column :users, :otp_enabled, :boolean, default: false
end
end
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
def enable_two_factor!
self.otp_secret = ROTP::Base32.random
self.otp_enabled = true
save!
end
def verify_otp(code)
return false unless otp_enabled?
totp = ROTP::TOTP.new(otp_secret)
totp.verify(code, drift_behind: 15, drift_ahead: 15)
end
def provisioning_uri
ROTP::TOTP.new(otp_secret).provisioning_uri(email)
end
end
# app/controllers/two_factors_controller.rb
class TwoFactorsController < ApplicationController
before_action :authenticate_user!
def new
@user = current_user
@provisioning_uri = @user.provisioning_uri
end
def create
if current_user.enable_two_factor!
redirect_to two_factor_path, notice: "Two-factor authentication enabled"
else
redirect_to new_two_factor_path, alert: "Could not enable two-factor"
end
end
end
OAuth Integration (Google/GitHub/etc.) #
# Gemfile
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-github'
gem 'omniauth-rails_csrf_protection'
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET']
provider :github, ENV['GITHUB_CLIENT_ID'], ENV['GITHUB_CLIENT_SECRET']
end
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
# Allow users without passwords (OAuth-only accounts)
validates :password, length: { minimum: 12 }, if: :password_required?
private
def password_required?
password_digest.nil? || password.present?
end
end
# app/controllers/oauth_callbacks_controller.rb
class OauthCallbacksController < ApplicationController
def google
auth = request.env['omniauth.auth']
user = User.find_or_create_by(email: auth['info']['email']) do |u|
u.password = SecureRandom.hex(32) # Random password for OAuth users
end
session[:user_id] = user.id
redirect_to root_path, notice: "Signed in with Google"
end
def github
# Similar implementation for GitHub
end
def failure
redirect_to new_session_path, alert: "Authentication failed"
end
end
Session Management and Device Tracking #
# db/migrate/[timestamp]_create_sessions.rb
class CreateSessions < ActiveRecord::Migration[8.0]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :token, null: false
t.string :ip_address
t.string :user_agent
t.datetime :last_accessed_at
t.timestamps
end
add_index :sessions, :token, unique: true
end
end
# app/models/session.rb
class Session < ApplicationRecord
belongs_to :user
before_create :generate_token
private
def generate_token
self.token = SecureRandom.hex(32)
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session_record = user.sessions.create!(
ip_address: request.remote_ip,
user_agent: request.user_agent,
last_accessed_at: Time.current
)
session[:session_token] = session_record.token
redirect_to root_path, notice: "Signed in successfully"
else
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
end
Performance Characteristics #
Rails 8’s built-in authentication has fewer dependencies and executes fewer database queries per request than Devise. Dropping Devise, Warden, and their transitive dependencies also reduces memory usage per Rails process. If you’re also moving to Solid Cache and Solid Queue , the combined dependency reduction is significant.
Step-by-Step Migration from Devise to Rails 8 Authentication #
Migrating from Devise to Rails 8’s built-in authentication requires careful planning to preserve user sessions, maintain data integrity, and avoid service disruption. This step-by-step guide ensures a smooth transition.
Phase 1: Pre-Migration Assessment #
Inventory Current Devise Configuration #
# Audit your Devise setup
$ grep -r "devise" Gemfile
$ cat config/initializers/devise.rb | wc -l # How many lines of config?
$ grep -r "devise_for" config/routes.rb
$ find app -name "*.rb" -exec grep -l "devise" {} \;
Document which Devise modules you’re using:
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :timeoutable,
:trackable, :omniauthable
# Which modules are actually being used?
end
Map Devise Features to Rails 8 Equivalents #
devise_to_rails8_mapping = {
database_authenticatable: "Built-in (has_secure_password)",
registerable: "Built-in (registration controller)",
recoverable: "Built-in (passwords controller)",
rememberable: "Custom implementation needed",
validatable: "Built-in (model validations)",
confirmable: "Built-in (email confirmations controller)",
lockable: "Custom implementation needed",
timeoutable: "Custom implementation needed",
trackable: "Custom implementation needed",
omniauthable: "OmniAuth gem integration"
}
Assess Migration Complexity #
# Calculate migration effort
assessment = {
users_count: User.count,
devise_modules: 6, # From your user model
custom_controllers: Dir["app/controllers/**/users/**/*.rb"].count,
custom_views: Dir["app/views/devise/**/*.erb"].count,
password_encryption: "bcrypt", # Check devise.rb
estimated_hours: 40 # Baseline for medium complexity
}
Phase 2: Preparing Your Application #
Create Parallel Authentication System #
Don’t remove Devise immediately. Build Rails 8 authentication alongside it:
# Generate Rails 8 authentication
$ rails generate authentication
# This creates new controllers, but don't touch Devise yet
# New files:
# - app/controllers/sessions_controller.rb (new)
# - app/controllers/passwords_controller.rb (new)
# - app/models/concerns/authenticatable.rb (new)
Rename to avoid conflicts: #
$ mv app/controllers/sessions_controller.rb app/controllers/rails8_sessions_controller.rb
$ mv app/controllers/passwords_controller.rb app/controllers/rails8_passwords_controller.rb
Add Rails 8 Authentication Columns #
# db/migrate/[timestamp]_add_rails8_auth_to_users.rb
class AddRails8AuthToUsers < ActiveRecord::Migration[8.0]
def change
# Don't rename encrypted_password yet - keep both during migration
# Guard against existing columns (for Devise apps)
add_column :users, :password_digest, :string unless column_exists?(:users, :password_digest)
add_column :users, :confirmed_at, :datetime unless column_exists?(:users, :confirmed_at)
add_column :users, :confirmation_sent_at, :datetime unless column_exists?(:users, :confirmation_sent_at)
end
end
Migrate Password Hashes #
Devise uses encrypted_password with BCrypt. Rails 8’s has_secure_password uses password_digest with BCrypt. They’re compatible!
# lib/tasks/migrate_passwords.rake
namespace :auth do
desc "Migrate Devise encrypted_password to Rails 8 password_digest"
task migrate_passwords: :environment do
User.find_each do |user|
if user.encrypted_password.present? && user.password_digest.nil?
user.update_column(:password_digest, user.encrypted_password)
end
end
puts "Migrated #{User.where.not(password_digest: nil).count} passwords"
end
end
$ bin/rails auth:migrate_passwords
Test Password Authentication Compatibility #
# rails console
user = User.first
# Test Devise authentication still works
user.valid_password?("password123") # => true
# Test Rails 8 authentication works with same password
user.authenticate("password123") # => #<User id: 1...>
Phase 3: Implementing Rails 8 Authentication #
Update User Model #
# app/models/user.rb
class User < ApplicationRecord
# Keep Devise temporarily
devise :database_authenticatable, :registerable, :recoverable
# Add Rails 8 authentication
has_secure_password validations: false # Disable auto-validations to avoid conflicts
# Custom validations
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 12 }, allow_nil: true,
if: :password_digest_changed?
normalizes :email, with: -> email { email.strip.downcase }
# Token generation for password reset and email confirmation
generates_token_for :password_reset, expires_in: 15.minutes do
password_digest&.last(10)
end
generates_token_for :email_confirmation, expires_in: 24.hours do
email
end
end
Create Rails 8 Controllers #
# app/controllers/rails8_sessions_controller.rb
class Rails8SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
# Use different session key to avoid conflicts
session[:rails8_user_id] = user.id
redirect_to root_path, notice: "Signed in with Rails 8 auth"
else
flash.now[:alert] = "Invalid credentials"
render :new, status: :unprocessable_entity
end
end
def destroy
session.delete(:rails8_user_id)
redirect_to root_path, notice: "Signed out"
end
end
Dual Authentication Helper #
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
private
def current_user
# Try Rails 8 auth first, fall back to Devise
@current_user ||= rails8_current_user || devise_current_user
end
helper_method :current_user
def rails8_current_user
return unless session[:rails8_user_id]
@rails8_current_user ||= User.find_by(id: session[:rails8_user_id])
end
def devise_current_user
# Devise's current_user method
super
end
def user_signed_in?
current_user.present?
end
helper_method :user_signed_in?
end
Add Feature Flag for Gradual Rollout #
# lib/auth_migration.rb
class AuthMigration
def self.use_rails8_auth?(user)
# Gradual rollout: 10% of users, then increase
Digest::MD5.hexdigest(user.id.to_s).to_i(16) % 100 < rollout_percentage
end
def self.rollout_percentage
ENV.fetch('RAILS8_AUTH_ROLLOUT', '10').to_i
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user && AuthMigration.use_rails8_auth?(user)
# Redirect to Rails 8 authentication
# SECURITY: Never forward raw params (contains password)
redirect_to rails8_session_path(email: params[:email])
else
# Use Devise authentication
super
end
end
end
Phase 4: Data Migration and Validation #
Migrate Confirmable Data #
# lib/tasks/migrate_confirmable.rake
namespace :auth do
desc "Migrate Devise confirmable data"
task migrate_confirmable: :environment do
User.where.not(confirmed_at: nil).find_each do |user|
# Devise confirmed_at → Rails 8 confirmed_at
user.update_column(:confirmed_at, user.confirmed_at) if user.confirmed_at
end
puts "Migrated confirmation data for #{User.where.not(confirmed_at: nil).count} users"
end
end
Test Authentication Flows #
# spec/features/authentication_spec.rb
RSpec.describe "Authentication migration", type: :feature do
let(:user) { create(:user, email: "test@example.com", password: "SecurePassword123!") }
describe "sign in flow" do
it "works with Rails 8 authentication" do
visit rails8_new_session_path
fill_in "Email", with: user.email
fill_in "Password", with: "SecurePassword123!"
click_button "Sign in"
expect(page).to have_content "Signed in successfully"
expect(current_path).to eq root_path
end
it "maintains Devise authentication" do
visit new_user_session_path # Devise path
fill_in "Email", with: user.email
fill_in "Password", with: "SecurePassword123!"
click_button "Sign in"
expect(page).to have_content "Signed in successfully"
end
end
describe "password reset flow" do
it "works with Rails 8" do
visit rails8_new_password_path
fill_in "Email", with: user.email
click_button "Send reset instructions"
expect(page).to have_content "Password reset instructions sent"
end
end
end
Verify Data Integrity #
# lib/tasks/verify_migration.rake
namespace :auth do
desc "Verify authentication migration data integrity"
task verify: :environment do
checks = {
users_with_password_digest: User.where.not(password_digest: nil).count,
users_with_encrypted_password: User.where.not(encrypted_password: nil).count,
users_confirmed: User.where.not(confirmed_at: nil).count,
password_compatibility: 0
}
# Test password compatibility (read-only validation)
User.limit(100).each do |user|
next unless user.encrypted_password.present?
# Validate digest format without mutating user data
if user.password_digest.present? && user.encrypted_password.present?
# Check that both digests exist and are properly formatted
if BCrypt::Password.valid_hash?(user.password_digest) &&
user.encrypted_password.start_with?('$2a$')
checks[:password_compatibility] += 1
end
end
end
puts JSON.pretty_generate(checks)
if checks[:users_with_password_digest] != checks[:users_with_encrypted_password]
raise "Password migration incomplete!"
end
end
end
Phase 5: Switching Over to Rails 8 #
Gradual Traffic Migration #
# config/initializers/auth_rollout.rb
class AuthRollout
ROLLOUT_SCHEDULE = {
week_1: 10, # 10% of traffic
week_2: 25, # 25% of traffic
week_3: 50, # 50% of traffic
week_4: 75, # 75% of traffic
week_5: 100 # 100% of traffic (complete migration)
}
def self.current_percentage
ENV.fetch('AUTH_ROLLOUT_PERCENTAGE', '10').to_i
end
def self.use_rails8_auth?(user_id)
Digest::MD5.hexdigest(user_id.to_s).to_i(16) % 100 < current_percentage
end
end
Update Routes #
# config/routes.rb
Rails.application.routes.draw do
# Rails 8 authentication routes (new)
resource :session, only: [:new, :create, :destroy]
resources :passwords, only: [:new, :create, :edit, :update]
resources :registrations, only: [:new, :create]
# Keep Devise routes temporarily
devise_for :users
# Root and other routes...
end
Monitor Migration Progress #
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
around_action :track_auth_method
private
def track_auth_method
auth_method = if session[:rails8_user_id]
'rails8'
elsif user_signed_in? # Devise
'devise'
else
'anonymous'
end
Rails.logger.info "Auth method: #{auth_method} for request #{request.path}"
# Send to monitoring system (e.g., New Relic, DataDog)
StatsD.increment("auth.method.#{auth_method}")
yield
end
end
Remove Devise (Final Step) #
Once 100% of traffic is on Rails 8 authentication and monitoring confirms stability:
# 1. Remove Devise gem
# Gemfile
# gem 'devise' # Remove this line
$ bundle install
# 2. Remove Devise configuration
$ rm config/initializers/devise.rb
$ rm config/locales/devise.en.yml
# 3. Remove Devise routes
# config/routes.rb
# Remove: devise_for :users
# 4. Clean up User model
# app/models/user.rb
class User < ApplicationRecord
# Remove: devise :database_authenticatable, ...
has_secure_password
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 12 }, if: :password_digest_changed?
normalizes :email, with: -> email { email.strip.downcase }
end
# 5. Drop Devise columns (after thorough testing)
# db/migrate/[timestamp]_remove_devise_columns.rb
class RemoveDeviseColumns < ActiveRecord::Migration[8.0]
def change
remove_column :users, :encrypted_password, :string
remove_column :users, :reset_password_token, :string
remove_column :users, :reset_password_sent_at, :datetime
remove_column :users, :remember_created_at, :datetime
remove_column :users, :sign_in_count, :integer
remove_column :users, :current_sign_in_at, :datetime
remove_column :users, :last_sign_in_at, :datetime
remove_column :users, :current_sign_in_ip, :string
remove_column :users, :last_sign_in_ip, :string
remove_column :users, :confirmation_token, :string
remove_column :users, :unconfirmed_email, :string
end
end
Production Deployment and Security Considerations #
Migrating authentication systems in production requires careful attention to security, monitoring, and rollback procedures.
Security Hardening #
Implement Rate Limiting #
# Gemfile
gem 'rack-attack'
# config/initializers/rack_attack.rb
class Rack::Attack
# Throttle login attempts by email
throttle("logins/email", limit: 5, period: 20.seconds) do |req|
if req.path == "/session" && req.post?
req.params['email'].to_s.downcase.gsub(/\s+/, "")
end
end
# Throttle login attempts by IP
throttle("logins/ip", limit: 10, period: 60.seconds) do |req|
req.ip if req.path == "/session" && req.post?
end
# Throttle password reset requests
throttle("password_resets/ip", limit: 3, period: 60.seconds) do |req|
req.ip if req.path == "/passwords" && req.post?
end
# Block IPs with suspicious activity
blocklist("bad_actors") do |req|
BadActorList.include?(req.ip)
end
end
Secure Session Configuration #
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
key: '_myapp_session',
secure: Rails.env.production?, # HTTPS only in production
httponly: true, # Prevent JavaScript access
same_site: :lax, # CSRF protection
expire_after: 2.weeks # Session expiration
Password Strength Enforcement #
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
validate :password_complexity
private
def password_complexity
return if password.blank?
errors.add :password, "must include at least one lowercase letter" unless password.match(/[a-z]/)
errors.add :password, "must include at least one uppercase letter" unless password.match(/[A-Z]/)
errors.add :password, "must include at least one digit" unless password.match(/\d/)
errors.add :password, "must include at least one special character" unless password.match(/[^A-Za-z0-9]/)
end
end
Implement Account Lockout #
# db/migrate/[timestamp]_add_lockout_to_users.rb
class AddLockoutToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :failed_login_attempts, :integer, default: 0
add_column :users, :locked_at, :datetime
end
end
# app/models/user.rb
class User < ApplicationRecord
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION = 30.minutes
def increment_failed_login!
increment!(:failed_login_attempts)
if failed_login_attempts >= MAX_LOGIN_ATTEMPTS
update!(locked_at: Time.current)
end
end
def reset_failed_login!
update!(failed_login_attempts: 0, locked_at: nil)
end
def locked?
locked_at.present? && locked_at > LOCKOUT_DURATION.ago
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.locked?
flash.now[:alert] = "Account locked due to too many failed login attempts"
render :new, status: :unprocessable_entity
return
end
if user&.authenticate(params[:password])
user.reset_failed_login!
session[:user_id] = user.id
redirect_to root_path, notice: "Signed in successfully"
else
user&.increment_failed_login!
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
end
Monitoring and Alerting #
Authentication Metrics Dashboard #
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
around_action :track_authentication_metrics
private
def track_authentication_metrics
start = Time.current
yield
duration = Time.current - start
if user_signed_in?
StatsD.timing("auth.login_duration", duration * 1000)
StatsD.increment("auth.successful_login")
end
rescue => e
StatsD.increment("auth.error.#{e.class.name}")
raise
end
end
Failed Login Monitoring #
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
# Success
session[:user_id] = user.id
log_successful_login(user)
else
# Failure
log_failed_login(params[:email], request.ip)
flash.now[:alert] = "Invalid credentials"
render :new, status: :unprocessable_entity
end
end
private
def log_successful_login(user)
Rails.logger.info "Successful login: user_id=#{user.id} ip=#{request.ip}"
StatsD.increment("auth.login.success")
end
def log_failed_login(email, ip)
Rails.logger.warn "Failed login: email=#{email} ip=#{ip}"
StatsD.increment("auth.login.failure")
# Alert on suspicious activity
if FailedLoginTracker.suspicious?(email, ip)
Sentry.capture_message("Suspicious login activity detected",
extra: { email: email, ip: ip })
end
end
end
Security Audit Logging #
# app/models/audit_log.rb
class AuditLog < ApplicationRecord
belongs_to :user, optional: true
enum event_type: {
login: 0,
logout: 1,
password_change: 2,
password_reset_request: 3,
email_confirmation: 4,
account_locked: 5,
account_unlocked: 6
}
def self.log_event(event_type, user: nil, metadata: {})
create!(
event_type: event_type,
user: user,
metadata: metadata.merge(
ip_address: Current.ip_address,
user_agent: Current.user_agent,
timestamp: Time.current
)
)
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
AuditLog.log_event(:login, user: user)
redirect_to root_path
else
AuditLog.log_event(:login, metadata: { email: params[:email], success: false })
render :new, status: :unprocessable_entity
end
end
def destroy
AuditLog.log_event(:logout, user: current_user)
session.delete(:user_id)
redirect_to root_path
end
end
Rollback Strategy #
Maintain Dual Authentication During Rollout #
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
private
def current_user
@current_user ||= begin
# Try Rails 8 authentication first
if session[:rails8_user_id]
User.find_by(id: session[:rails8_user_id])
# Fall back to Devise if Rails 8 user not found
elsif defined?(Devise) && respond_to?(:devise_current_user)
devise_current_user
end
end
end
helper_method :current_user
end
Instant Rollback Capability #
# lib/auth_rollback.rb
class AuthRollback
def self.execute!
# Stop using Rails 8 authentication immediately
ENV['RAILS8_AUTH_ENABLED'] = 'false'
# Clear Rails 8 sessions
Redis.current.keys("session:rails8:*").each do |key|
Redis.current.del(key)
end
# Log rollback event
Rails.logger.error "Authentication rollback executed at #{Time.current}"
Sentry.capture_message("Authentication system rolled back to Devise")
true
end
end
# Can be triggered via Rails console or admin interface
$ rails runner "AuthRollback.execute!"
Production Deployment Checklist #
Pre-Deployment: #
- Complete data migration (passwords, confirmations)
- Verify test suite passes (100% of authentication tests)
- Security audit completed (penetration testing, code review)
- Monitoring dashboards configured
- Rollback procedure documented and tested
- Team training completed
Deployment (Gradual Rollout): #
- Week 1: Enable for 10% of users
- Monitor error rates, failed logins, support tickets
- Week 2: Increase to 25% if metrics healthy
- Week 3: Increase to 50%
- Week 4: Increase to 75%
- Week 5: Complete migration to 100%
Post-Deployment: #
- Monitor authentication metrics for 30 days
- Verify no increase in failed logins
- Confirm password reset flow working correctly
- Remove Devise gem and dependencies
- Clean up database (remove unused Devise columns)
- Update documentation
When NOT to Migrate Off Devise #
Not every app should make this move. Stay on Devise if:
- You need OAuth as a core feature. Rails 8 auth doesn’t include OAuth. You can wire up OmniAuth yourself (we showed how above), but if you support 5+ OAuth providers with account linking, Devise’s omniauthable module saves real time.
- You have 20+ custom auth flows. Lockable, timeoutable, trackable, confirmable with re-confirmation, remember-me with token rotation – if you actively use most of Devise’s modules, rebuilding them all is a month of work with diminishing returns.
- Your app is stable and auth isn’t causing problems. Migration carries risk. If Devise works, your team understands it, and you’re not hitting performance issues, the migration cost may exceed the benefit. Ship features instead.
- You’re mid-upgrade to Rails 7. Finish the Rails upgrade first. Swapping auth systems and Rails versions simultaneously is how you end up locked out of production on a Friday night.
The honest test: count how many Devise modules your User model actually declares, then count how many your app exercises in production. If the gap is small, Devise is earning its keep.
What to Do Next #
If you’re starting fresh on Rails 8, skip Devise entirely. Run rails generate authentication and build only what you need.
If you’re migrating, start with Phase 1: audit your Devise usage and map it to Rails 8 equivalents. The password hashes are compatible – that’s the hardest part already solved. Run dual auth in production for at least two weeks before cutting over.
For related reading: our Argon2 migration guide covers upgrading password hashing beyond BCrypt, and the Rails 8 authentication generator overview walks through the generated code in detail.
For teams undertaking auth migrations or needing security guidance, our Rails development team has done this migration three times in production – we can help you avoid the sharp edges.