Rails `has_secure_password` with Argon2: Complete Migration Guide

If your app stores user passwords, the encryption method matters. Older methods can be cracked cheaply with modern hardware. This guide helps your dev team upgrade to a stronger standard without breaking login for existing users. Send it to whoever manages your authentication.
BCrypt dominated Rails authentication for a decade. Then GPUs got cheap. A $3,000 rig cracks BCrypt cost-12 at roughly 65,000 hashes per second. Against Argon2id with 256MB memory cost, that same rig manages about 10 — the memory requirement kills GPU parallelism. The economics make the attack pointless. Rails finally caught up.
has_secure_password supports Argon2 natively starting in Rails 8.2 (currently on edge, not yet released as stable). But if you flip the switch on a production app with existing BCrypt digests, every login breaks. That’s the trap most teams walk into.
We migrated 50K fintech users to Argon2 with zero support tickets. Here’s exactly how. The key is a Hybrid Verifier pattern that dual-verifies both algorithms and rehashes on login – no mass password reset, no downtime, no angry users.
What changed in Rails #
Rails 8.2 added native algorithm support to has_secure_password (PR #56041
, PR #56057
, merged October 2025). It 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 isn’t broken – but 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 doesn’t scale cheaply.
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
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 (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 – most apps see 80%+ conversion within two weeks of active users logging in.
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, consider pairing this with encrypted data compression in Rails 8 – defense in depth matters. 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