Rails `has_secure_password` with Argon2: Complete Migration Guide
Rails keeps improving built-in authentication, and one of the most important security upgrades is now straightforward: using Argon2 with has_secure_password.
Recent Rails weekly updates highlighted both the new :algorithm option and built-in Argon2 support for has_secure_password. If your app still uses BCrypt only, this is a good time to plan migration with clear, low-risk steps.
This guide covers:
- What changed in Rails
- Why Argon2 matters
- How to enable Argon2 in a new app
- How to migrate from existing BCrypt users without breaking logins
- Rollout and operational checklist
What changed in Rails #
Rails has_secure_password now supports:
algorithm:option- Built-in
:argon2support (with theargon2gem) - Password algorithm registry for custom strategies
This means you can do:
class User < ApplicationRecord
has_secure_password algorithm: :argon2
end
And Rails will use Argon2 for hashing and verification through the secure password API.
Why move to Argon2 #
BCrypt is still solid and battle-tested, but Argon2 (especially Argon2id variants in modern libraries) is widely preferred in newer systems because of stronger memory-hard design.
In practical product terms, Argon2 gives teams:
- Better resistance profile against modern brute-force hardware
- Flexible tuning space (time and memory costs)
- A modern default for newly created credentials
If you are touching authentication anyway (new login flows, Rails upgrades, security hardening), upgrading password hashing is usually high leverage.
Minimal setup for new apps #
Add dependency:
# Gemfile
gem "argon2", "~> 2.3"
Bundle:
bundle install
Enable algorithm:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password algorithm: :argon2
end
That is enough for fresh users and fresh password resets.
The migration trap most teams hit #
If you switch an existing model directly from BCrypt to Argon2, existing BCrypt digests may fail verification depending on your strategy, because the verifier now expects Argon2 digests.
So production migration should be phased, not a one-line flip.
Safe migration strategy (recommended) #
Use three phases:
- Dual-verify (BCrypt + Argon2), still creating new hashes with Argon2
- Opportunistic rehash on successful login
- Remove BCrypt fallback after migration window
Phase 1: Dual algorithm verifier #
Rails allows custom password algorithms via ActiveModel::SecurePassword.register_algorithm.
Create a hybrid algorithm adapter:
# config/initializers/secure_password_algorithms.rb
class HybridPassword
def initialize
require "bcrypt"
require "argon2"
end
# Always create new hashes with Argon2
def hash_password(unencrypted_password)
Argon2::Password.create(unencrypted_password)
end
# Verify both old BCrypt and new Argon2 digests
def verify_password(password, digest)
return false if digest.blank?
if bcrypt_digest?(digest)
BCrypt::Password.new(digest).is_password?(password)
else
Argon2::Password.verify_password(password, digest)
end
rescue StandardError
false
end
# Needed by has_secure_password API
def password_salt(digest)
return nil if digest.blank?
return BCrypt::Password.new(digest).salt if bcrypt_digest?(digest)
# Argon2 stores all params in digest; explicit salt extraction is not required
nil
rescue StandardError
nil
end
# BCrypt has a practical input limit, keep validation conservative
def validate(record, attribute)
password = record.public_send(attribute)
return if password.blank?
if password.bytesize > 72 && record.public_send("#{attribute}_digest").to_s.start_with?("$2")
record.errors.add(attribute, :password_too_long)
end
end
def algorithm_name
:hybrid
end
private
def bcrypt_digest?(digest)
digest.start_with?("$2a$", "$2b$", "$2y$")
end
end
ActiveModel::SecurePassword.register_algorithm :hybrid, HybridPassword
Use it in the model:
class User < ApplicationRecord
has_secure_password algorithm: :hybrid
end
Phase 2: Opportunistic rehash on login #
After a successful BCrypt login, rehash with Argon2 transparently:
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email].to_s.downcase.strip)
return render_invalid unless user&.authenticate(params[:password])
if bcrypt_digest?(user.password_digest)
user.password = params[:password]
user.password_confirmation = params[:password]
user.save!(validate: false)
end
# continue normal sign-in
# ...
end
private
def bcrypt_digest?(digest)
digest.to_s.start_with?("$2a$", "$2b$", "$2y$")
end
def render_invalid
# your failure response
end
end
This avoids mass reset campaigns and upgrades active users naturally.
Phase 3: Remove fallback #
When most active accounts are rehashed (for example 95%+), switch:
class User < ApplicationRecord
has_secure_password algorithm: :argon2
end
Then retire BCrypt fallback code.
Password reset flow strategy #
Password reset is your second migration engine after login.
- Keep reset token expiration short (
has_secure_password reset_token: { expires_in: 15.minutes }or tighter) - Ensure new passwords always save with Argon2
- Monitor reset success/failure rates during rollout
This helps convert inactive BCrypt accounts gradually without forced lockouts.
Operational checklist for rollout #
Before deploy:
- Add metrics for login success by digest prefix (
$2*vs$argon2*) - Add alert on authentication error spikes
- Add feature flag for hybrid verifier if your team uses staged releases
During deploy:
- Roll out read path first (dual verify)
- Then write path (Argon2 creation)
- Track conversion percentage daily
After deploy:
- Remove stale BCrypt digests only after stable conversion period
- Rotate runbooks and onboarding docs to Argon2-first assumptions
Testing plan #
Cover these paths explicitly:
- New user registration creates Argon2 digest
- Existing BCrypt digest authenticates successfully
- Existing BCrypt digest upgrades to Argon2 after login
- Invalid credentials still fail in constant-time path assumptions
- Password reset creates Argon2 digest
- Legacy user can still reset and login
Example model test sketch:
require "test_helper"
class UserPasswordAlgorithmTest < ActiveSupport::TestCase
test "new password uses argon2 digest" do
user = User.create!(email: "a@b.com", password: "StrongerP@ssword1", password_confirmation: "StrongerP@ssword1")
assert user.password_digest.start_with?("$argon2")
end
end
Common mistakes #
- Switching to
algorithm: :argon2without a backward verification plan - Not instrumenting conversion progress
- Doing a full forced reset without product comms
- Ignoring authentication throughput impact during peak traffic
Suggested migration timeline #
Week 1:
- Ship hybrid verifier + metrics
Week 2-4:
- Monitor opportunistic conversion via login and resets
Week 5+:
- If conversion is high and stable, switch to pure Argon2
For low-activity consumer apps, run longer before removing fallback.
Final recommendation #
If you are on modern Rails and already using has_secure_password, move to Argon2 with a phased strategy. It gives you stronger defaults with minimal product disruption when done carefully.
Keep it simple:
- Dual-verify
- Rehash on successful login
- Remove legacy path only after data confirms safety
That is the lowest-risk path to better password security in production Rails.
References #
- This Week in Rails (April 5, 2026): https://rubyonrails.org/2026/4/5/this-week-in-rails
- ActiveModel
has_secure_passwordAPI (edge): https://edgeapi.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html - ActiveModel::SecurePassword (algorithm registry): https://edgeapi.rubyonrails.org/classes/ActiveModel/SecurePassword.html
- Argon2 password gem: https://github.com/technion/ruby-argon2