LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

Argon2 in has_secure_password, and the variant nobody named

The Rails 8.2 edge release notes describe the change in one sentence: "Add built-in Argon2 support for has_secure_password via algorithm: :argon2. Argon2 has no password length limit, unlike BCrypt's 72-byte restriction." The sentence says Argon2. Argon2 is a family with three members, two of which you should not be hashing passwords with, and the note names none of them. So the first question is not whether to migrate. It is what the thing actually writes into your column.

Everything below was run on this machine today: Apple M2 Max, 12 cores, macOS arm64-darwin25, Ruby 4.0.5, bcrypt 3.1.22, argon2 2.3.3. Rails 8.2 does not exist as a gem yet, so for the model-level experiments I copied three files from rails/rails main over the installed activemodel 8.1.3.1: activemodel/lib/active_model/secure_password.rb and the two new adapters beside it, secure_password/bcrypt_password.rb and secure_password/argon2_password.rb. The Active Record models in those experiments run against sqlite3 in memory. The benchmarks touch no database at all, because the thing being measured is a hash function.

It is argon2id, and Rails is not the one deciding that

Rails writes an argon2id digest. Here is one, out of a model declared with algorithm: :argon2:

$argon2id$v=19$m=65536,t=3,p=4$z8aN465iuEg2dexzf9hdBA$JlrTBeHqLb/YOuU6m5NGEulpZM1Jhg/NnS7SK3r51SU

The part worth knowing is where the id comes from, because it is not from Rails. The whole of ActiveModel::SecurePassword::Argon2Password#hash_password on main is this:

def hash_password(unencrypted_password)
  if ActiveModel::SecurePassword.min_cost
    ::Argon2::Password.new(profile: :unsafe_cheapest).create(unencrypted_password)
  else
    ::Argon2::Password.create(unencrypted_password)
  end
end

No variant is requested. The choice is made one layer down, in argon2-2.3.3/lib/argon2.rb, where Argon2::Password#create ends on a single unconditional call:

Argon2::Engine.hash_argon2id_encode(
  pass, salt, @t_cost, @m_cost, @p_cost, @secret)

Argon2::Engine does attach argon2i_hash_raw as well as argon2id_hash_raw, so the FFI binding for argon2i exists in the gem. Argon2::Password#create has no path that reaches it. There is no variant: option to pass, no profile that switches it, and nothing a Rails application can say to get anything other than argon2id out of has_secure_password.

That is the right answer, and it is right by inheritance rather than by decision. If ruby-argon2 ever grows a variant option with a different default, has_secure_password will follow it silently, because Rails calls the zero-argument form. Worth knowing before you write "Rails uses Argon2id" in a compliance document: the framework does not say that anywhere, and the sentence is true of argon2 2.3.3 specifically.

It is an opt-in, and bcrypt is still the default

bcrypt remains the default in has_secure_password on main. The resolver is four lines, and the nil branch is the one that runs for every existing application:

algorithm = case algorithm
when Symbol
  algorithm_class = ActiveModel::SecurePassword.lookup_algorithm(algorithm)
  raise ArgumentError, "Unknown password algorithm: #{algorithm.inspect}" unless algorithm_class
  algorithm_class.new
when nil
  BCryptPassword.new
else
  algorithm
end

Nothing in rails/rails#56057 changes a default, adds a configuration flag, or touches load_defaults. A typo in the symbol is at least loud: has_secure_password algorithm: :scrypt raises ArgumentError: Unknown password algorithm: :scrypt while the class body is being evaluated, so it is a boot failure rather than a login failure.

The parameters are pinned, and the only way out is a new class

m=65536,t=3,p=4 is what every Rails application using algorithm: :argon2 will write, and there is no option to change it. Argon2Password#initialize takes no arguments and does nothing but require "argon2"; hash_password calls ::Argon2::Password.create with one argument. The parameters come from three constants in the gem:

DEFAULT_T_COST = Argon2::Profiles::RFC_9106_LOW_MEMORY[:t_cost]
DEFAULT_M_COST = Argon2::Profiles::RFC_9106_LOW_MEMORY[:m_cost]
DEFAULT_P_COST = Argon2::Profiles::RFC_9106_LOW_MEMORY[:p_cost]

RFC_9106_LOW_MEMORY is { t_cost: 3, m_cost: 16, p_cost: 4 }, and the gem's comment above it reads "SECOND RECOMMENDED option per RFC 9106". m_cost is a power of two exponent, so 16 means 2^16 KiB, which is the 65536 in the digest and 64 MiB of working memory per hash. The FIRST RECOMMENDED option in the same file, RFC_9106_HIGH_MEMORY, is m_cost: 21, meaning 2 GiB per hash. Rails does not offer it, which is a mercy.

Sixty-four mebibytes is well above what OWASP asks for. The Password Storage Cheat Sheet lists "m=19456 (19 MiB), t=2, p=1" among its recommended argon2id configurations, so Rails ships at roughly 3.4 times that memory and four lanes instead of one. Erring high is defensible. Being unable to err low is the part that bites, because 64 MiB per concurrent sign-in is a number your dyno has an opinion about, and the section below measures it.

If you need different parameters, the escape hatch is the other half of the same pull request: ActiveModel::SecurePassword.register_algorithm takes a name and a class implementing hash_password, verify_password, password_salt, validate and algorithm_name. Writing a nineteen-line class to change three integers is the price of the option not existing.

Argon2 is faster than bcrypt here, and that is the problem

Hashing one password, averaged over ten runs on the M2 Max, no database involved:

bcrypt create cost=10: 57.9 ms
bcrypt create cost=11: 115.6 ms
bcrypt create cost=12: 232.6 ms
bcrypt create cost=13: 464.4 ms
argon2 default create: 27.2 ms
argon2 default verify: 25.7 ms

BCrypt::Engine.cost is 12 by default and this application does not override it, so the honest comparison is 232.6 ms against 27.2 ms. Switching to Argon2 makes password hashing 8.5 times cheaper in wall clock time on this hardware.

That is not the upgrade it sounds like. A password hash is supposed to be expensive, and the two algorithms are not spending their budget in the same currency: bcrypt burns 232 ms of one core and about 4 KiB, while Argon2 burns 27 ms across four lanes and 64 MiB. Argon2 buys resistance to GPU and ASIC attack with memory, which is the thing a rented attack rig cannot multiply as cheaply as it multiplies cores. But if you compare only the number above and conclude you have upgraded, you have moved to an algorithm that a single attacker core finishes 8.5 times sooner, and the compensation is entirely in the memory term.

What twelve concurrent sign-ins do to a dyno

Memory is where Argon2 changes the shape of the application, and it is the part the release note does not mention. Each of these ran in a fresh Ruby process, with a sampling thread reading ps -o rss= every 5 ms:

fresh process,  1 concurrent argon2 hashes:  46 ms, rss baseline 19 MB, peak  84 MB
fresh process,  4 concurrent argon2 hashes: 112 ms, rss baseline 19 MB, peak 276 MB
fresh process,  8 concurrent argon2 hashes: 300 ms, rss baseline 19 MB, peak 533 MB
fresh process, 12 concurrent argon2 hashes: 318 ms, rss baseline 19 MB, peak 789 MB

The arithmetic is not subtle: 12 times 64 MiB is 768 MiB, plus a 19 MB baseline, and the measured peak was 789 MB. Both the argon2 gem's FFI bindings and bcrypt's C extension release the GVL (:blocking => true on every attach_function in argon2/ffi_engine.rb, rb_thread_call_without_gvl in bcrypt_ext.c), so a Puma worker really will run its threads' hashes at the same time, and really will hold that much at once.

A default Puma worker runs 5 threads. Five simultaneous sign-ins is 320 MiB of transient RSS on top of whatever the application already holds, which on a 512 MB Heroku dyno is the difference between running and an R14. The same test run with bcrypt at cost 12 went from a 24 MB baseline to a 25 MB peak in 343 ms, which is why nobody has ever had to think about this.

Throughput under concurrency, same machine, for completeness:

bcrypt12 x12 threads: 328 ms total, 36.6 hashes/s
argon2   x12 threads: 176 ms total, 68.1 hashes/s

Argon2 wins on throughput and loses on the thing that takes a process down.

The 72-byte limit, and what bcrypt does with the tail

The release note's second sentence is the strongest argument in the change, and it is easy to under-read. bcrypt does not reject a password longer than 72 bytes. It ignores everything past byte 72, silently, and Rails adds a validation to stop you finding out the hard way. Here is the hard way, with the validation out of the picture:

long = "a" * 72 + "THIS TAIL IS IGNORED"
d = BCrypt::Password.create(long, cost: 4)
BCrypt::Password.new(d).is_password?("a" * 72)                          # => true
BCrypt::Password.new(d).is_password?("a" * 72 + "DIFFERENT TAIL HERE!") # => true

Two different 92-byte passwords, one digest, both accepted. Argon2 returned false for the first and true only for the exact input. On the model side that is the difference between a validation and no validation at all: Argon2Password#validate is an empty method with a comment saying "Argon2 has no maximum input size, no validation needed", so a 100-character password is valid on an Argon2 model and produces Password is too long on a bcrypt one.

If your users are in a password manager generating long passphrases, this is real. If your signup form caps at 64 characters, it is not.

MAX_PASSWORD_LENGTH_ALLOWED moved

ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED no longer exists on main. ActiveModel::SecurePassword.const_defined?(:MAX_PASSWORD_LENGTH_ALLOWED, false) returns false there and true on 8.1.3.1. The constant is now ActiveModel::SecurePassword::BCryptPassword::MAX_PASSWORD_LENGTH_ALLOWED, still 72, which is correct given the constant was always a bcrypt fact. Anything referencing the old path breaks, and form objects that validate password length against the framework constant rather than hardcoding 72 are the likely casualty.

Switching an existing model raises, it does not return false

Adding algorithm: :argon2 to a model whose table is full of bcrypt digests does not cause failed logins. It causes exceptions. The verify path is ::Argon2::Password.verify_password(password, digest), which starts by calling valid_hash? and raises when the string is not an Argon2 digest:

argon2 model authenticating a bcrypt digest => Argon2::ArgonHashFail: Invalid hash
bcrypt model authenticating an argon2 digest => BCrypt::Errors::InvalidHash: invalid hash

Both directions raise, so a rollback is as loud as the deploy. Whatever your sign-in controller does with a nil from authenticate_by, it will not be doing it: the exception escapes authenticate_by and your users get a 500.

You cannot rehash your way out before the deploy, either, for the obvious reason. A bcrypt digest is not reversible, so the only moment you can produce an Argon2 digest for an existing account is the moment that account types its password into your form and you still have the plaintext in memory. Every migration strategy that exists is a variation on catching that moment.

The dual-read class that works, and the oracle it opens

The strategy that actually migrates a live table is a custom algorithm: hash with Argon2, verify against whichever format the stored digest is in, and rehash on successful login. register_algorithm is exactly the hook for it, and the class is short:

One sign in attempt against a model declared algorithm: :dual fans into three legs. An unknown address runs the new(passwords) decoy hash in argon2id and takes 24.8 ms, a user whose digest starts $argon2id$ verifies in 24.2 ms, and a user still holding a $2a$12$ bcrypt digest verifies in 228.4 ms. All three legs converge on the same nil, and the three times are drawn as bars in one shared track, where the first two are stubs and the bcrypt one fills the track.

class DualPassword
  def initialize
    @argon2 = ActiveModel::SecurePassword::Argon2Password.new
    @bcrypt = ActiveModel::SecurePassword::BCryptPassword.new
  end

  def for(digest) = ::Argon2::Password.valid_hash?(digest) ? @argon2 : @bcrypt

  def hash_password(pw) = @argon2.hash_password(pw)
  def verify_password(pw, digest) = self.for(digest).verify_password(pw, digest)
  def password_salt(digest) = self.for(digest).password_salt(digest)
  def validate(record, attribute) = nil
  def algorithm_name = :dual
end

ActiveModel::SecurePassword.register_algorithm :dual, DualPassword

Dispatch on Argon2::Password.valid_hash? rather than on rescue ArgonHashFail, because the gem raises out of the same method it would return false from and you would be using exceptions for control flow on your hottest auth path. password_salt has to dispatch too, and this is the part that is easy to miss: has_secure_password builds the password reset token from public_send("#{attribute}_salt")&.last(10), so an algorithm class that only dispatches verify_password will hand a bcrypt digest to Argon2::HashFormat and blow up every "forgot my password" for every unmigrated user. With the dispatch in place, the reset token generated for a bcrypt row was fine. Argon2 salts come back 22 bytes and bcrypt salts 29, and the token takes the last 10 of either.

The class works. Both rows authenticate, and user.update!(password: submitted) after a successful authenticate rewrites the digest as argon2id. Then I timed it, and this is where I stopped liking it:

authenticate_by, no such user:            24.8 ms
authenticate_by, bcrypt user wrong pass:  228.4 ms
authenticate_by, argon2 user wrong pass:  24.2 ms

authenticate_by exists for one reason, written into its own documentation: "Regardless of whether a record is found, authenticate_by will cryptographically digest the given password attributes. This behavior helps mitigate timing-based enumeration attacks." It does that by calling new(passwords) on the no-record branch, which runs the model's hash_password. Under DualPassword that decoy hash is Argon2, 24.8 ms, and it is now equalising against the wrong thing. A user who has not signed in since the deploy still has a bcrypt digest and answers in 228 ms. That 200 ms gap is a clean oracle: it does not just leak that an address is registered, it leaks that the account is dormant, which is a better target than a random registered one.

The gap closes on its own as users log in and get rehashed, so the window is bounded by how long you run dual mode. It does not close for the accounts that never come back, which are exactly the accounts the leak is most useful against. I have no fix for this that keeps authenticate_by: you would have to equalise by hand, which means timing your own decoy, which means reimplementing the method Rails wrote to stop you reimplementing it. Naming it beats not noticing it, and if you run a dual-read migration you should know the window is open the whole time.

The test suite gets faster, not slower

ActiveModel::SecurePassword.min_cost = Rails.env.test? is set at active_model/railtie.rb:18, and the Argon2 adapter honours it by switching to profile: :unsafe_cheapest, which is { t_cost: 1, m_cost: 3, p_cost: 1 }, 8 KiB. Averaged over 50 runs: bcrypt at MIN_COST 4 takes 1.04 ms, Argon2 at unsafe_cheapest takes 0.02 ms. A suite that creates a few thousand users gets a second or two back. Nobody should choose a password hash on this, but it is the one number that moves in the direction you would guess.

What I would do on this application, and what would change it

Nothing, for now, on this codebase. app/models/user.rb:34 is a bare has_secure_password, the Gemfile pins gem "bcrypt", "~> 3.1.7" and resolves to 3.1.22, BCrypt::Engine.cost is 12, and the development database has 6 users whose digests all start $2a$12$ and are all 60 bytes long. The column is character varying with no limit, so it will hold a 97-byte Argon2 digest without a migration, which is the one part of this that costs nothing.

The case against moving is that the benefit is real but small here and the costs are all operational. bcrypt at cost 12 is not a weak position; OWASP's floor is a work factor of 10 and this is two doublings above it. What Argon2 adds is memory hardness, which matters when someone has your digests and a GPU, and 64 MiB per concurrent hash is a live constraint on a small dyno in a way 232 ms of CPU is not.

What would change it: a password field with no length cap and users who paste long passphrases, since bcrypt's silent truncation is a genuine defect and no amount of cost tuning fixes it. A compliance requirement naming Argon2id, which is increasingly how these are written. Or Rails 8.2 actually shipping, which it has not: rubygems.org's newest rails today is 8.1.4, published 2026-09-24, and this work lives on main, merged 2025-10-31 in https://github.com/rails/rails/pull/56057 by Justin Bull and Guillermo Iguaran. Building an auth migration on a main branch API that may still change before release is a choice with no upside.

What this post does not cover

No benchmark here ran on the hardware you deploy to. An M2 Max has fast cores and enormous memory bandwidth, and Argon2 is more sensitive to both than bcrypt is; a shared vCPU on a 512 MB container will produce a different ratio and probably a worse one for Argon2. Run the numbers on your own target before quoting mine.

Also absent: the peppering that Argon2::Password.new(secret:) supports and has_secure_password does not expose; argon2d, which is the family member you do not want for passwords and which ruby-argon2 does not bind at all; the authenticate_by timing behaviour under a single algorithm, which is fine and is not what the oracle section is about; any statement about what third-party posts have written about this feature, because I could not check them while writing this; and what Rails 8.2 will actually ship, which is not decidable from main.

#rails #active-model

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.