Rails `has_secure_password` with Argon2: Complete Migration Guide

BCrypt dominated Rails authentication for a decade. Then GPUs got cheap, and BCrypt’s security margin shrank with them. Against Argon2id at a 256MB memory cost, the same hardware that brute-forces BCrypt at thousands of attempts per second drops to single digits because the memory requirement kills GPU parallelism. Rails is catching up: has_secure_password will support Argon2 natively in the upcoming Rails 8.2 (currently on edge, not yet released as stable).
If you flip the algorithm switch on a production app with existing BCrypt digests, every login breaks. That is the trap. The fix is a Hybrid Verifier pattern that dual-verifies both algorithms and rehashes on login, without a mass password reset and without downtime.
What changed in Rails #
Rails 8.2 (edge, unreleased at time of writing) is adding native algorithm support to has_secure_password. The feature work tracks through PR #56041
and PR #56057
, and the edge API will support:
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 isn’t broken; it’s outgunned. Argon2id is memory-hard by design, which means attackers can’t just throw more GPUs at it. They need proportionally more RAM, and RAM is the expensive part of a cracking rig.
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’re touching authentication anyway – new login flows, a Rails 8 upgrade , security hardening – upgrading the hash algorithm is the highest-leverage change you can make.
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
# Requires Rails 8.2+ (currently edge - API may change before GA).
require "bcrypt"
require "argon2"
class HybridPassword
# 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 Argon2::Errors::InvalidHash, BCrypt::Errors::InvalidHash
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 BCrypt::Errors::InvalidHash
nil
end
# validate(record, attribute) intentionally omitted: the write path is always
# Argon2, which has no 72-byte input cap. The previous BCrypt-style guard
# blocked legitimate password changes for users still on BCrypt digests.
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 (if you’re deploying with Kamal , feature flags via environment variables work well here)
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.
When NOT to migrate #
Be honest about when this isn’t worth the effort:
- You’re shipping a brand-new app with no existing users. Skip the hybrid verifier entirely. Just set
algorithm: :argon2and move on. - Your app uses Devise with custom strategies. The hybrid verifier pattern assumes
has_secure_password. Devise has its own password handling pipeline, and you’ll need to hook intoDevise::Encryptableinstead. Different migration path. - You have fewer than 100 users and can email them all. A forced password reset is simpler than maintaining dual-algorithm code. Send the email, reset everyone, delete the BCrypt code.
- Your authentication is handled by an external identity provider. If you’re using Auth0, Okta, or similar – your app doesn’t store password digests at all. This guide doesn’t apply.
- You’re on a Rails version older than 8.2. The
algorithm:option forhas_secure_passwordwas added in Rails 8.2. Upgrade Rails first, then come back.
What to do next #
Start with the hybrid verifier and metrics. Ship it behind a feature flag if your team does staged rollouts. Monitor the BCrypt-to-Argon2 conversion rate daily; on the two production apps we have shipped this on so far, daily-active users had rotated within fourteen days, and weekly-active users by week six.
If you’re also modernizing your auth stack, the Rails 8 authentication generator pairs well with this migration. And if you’re hardening more than just passwords, our post on authentication patterns in Rails 7.1 covers the broader picture.
For teams handling sensitive data, pair this with encrypted data compression in Rails 8 so the at-rest layer matches the new password discipline. And if you’re containerizing your deploys, our Rails 8 Docker production guide covers how to handle migrations safely during rollout.
References #
- This Week in Rails - Keep your passwords secure (November 7, 2025): https://rubyonrails.org/2025/11/7/this-week-in-rails
- PR #56041 - Add
:algorithmoption tohas_secure_password: https://github.com/rails/rails/pull/56041 - PR #56057 - Add built-in Argon2 support: https://github.com/rails/rails/pull/56057
- 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
- Argon2id RFC 9106: https://datatracker.ietf.org/doc/html/rfc9106
- OWASP Password Hashing Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- Argon2 vs. BCrypt Comparison: https://pbnjer.com/argon2-vs-bcrypt
- Argon2 password gem: https://github.com/technion/ruby-argon2