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:

  1. What changed in Rails
  2. Why Argon2 matters
  3. How to enable Argon2 in a new app
  4. How to migrate from existing BCrypt users without breaking logins
  5. Rollout and operational checklist

What changed in Rails #

Rails has_secure_password now supports:

  • algorithm: option
  • Built-in :argon2 support (with the argon2 gem)
  • 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.

Use three phases:

  1. Dual-verify (BCrypt + Argon2), still creating new hashes with Argon2
  2. Opportunistic rehash on successful login
  3. 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:

  1. New user registration creates Argon2 digest
  2. Existing BCrypt digest authenticates successfully
  3. Existing BCrypt digest upgrades to Argon2 after login
  4. Invalid credentials still fail in constant-time path assumptions
  5. Password reset creates Argon2 digest
  6. 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: :argon2 without 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:

  1. Dual-verify
  2. Rehash on successful login
  3. Remove legacy path only after data confirms safety

That is the lowest-risk path to better password security in production Rails.

References #