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:
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.
Comments
No comments yet. Be the first.