has_secure_password: everything one line gives you
September 06, 2026
has_secure_password is one line. It is also the most under-read line in a Rails model, because
almost everything it does is invisible until it bites.
has_secure_password
The full signature is has_secure_password(attribute = :password, validations: true, reset_token: true).
It requires a password_digest column, and bcrypt in your Gemfile:
gem"bcrypt","~> 3.1.7"
The validations you did not write
With validations: true, which is the default, three arrive automatically.
Presence on creation. Straightforward, and the reason a factory that forgets a password fails
loudly instead of creating a passwordless account.
Length of at most 72 bytes. This one is worth understanding rather than memorising. bcrypt
truncates input beyond 72 bytes, so without the validation a user with a 100-character passphrase
would have the tail silently ignored, and any password sharing its first 72 bytes would let them
in. Note that it is bytes, not characters: a passphrase of emoji or accented text hits the limit
sooner than it looks.
Confirmation.password_confirmation is checked against password, but only when it is
non-nil. Leave the field out of your form entirely and the validation never fires. That is the
documented behaviour, and it is why a password change form without a confirmation field simply
works rather than failing on a blank mismatch.
All three can be turned off with validations: false if you want complete control. This codebase
keeps them and adds one of its own, a PasswordComplexityValidator that only fires when a password
is actually being set.
password_challenge, the attribute almost nobody uses
has_secure_password also creates a password_challenge accessor. Set it to anything other than
nil and it validates against the currently persisted password.
That is exactly what you want on a "change your password" form, where the user must prove they know
the old one. The hand-rolled version is a manual user.authenticate(params[:current_password])
call in the controller; the built-in version is a validation, so it participates in the normal
error rendering with no extra branch.
One caveat from the documentation, and it explains the failures people hit with it: the validation
relies on dirty tracking through ActiveModel::Dirty. On a plain Active Record model that is free.
On a form object or a ActiveModel::Model without dirty tracking defined, the validation fails.
This codebase does not use it yet. The password change path goes through the reset token rather than
a current-password challenge, which is a defensible choice for a product where most accounts arrive
through OAuth or a pay-first checkout and may never have typed a password at all.
The reset token, and its fifteen minutes
With reset_token: true, again the default, and a model that responds to generates_token_for,
which every Active Record does, you get a password reset token for free.
It is valid for 15 minutes by default. That is short, deliberately, and it is a number worth
knowing before a user tells you the link in their inbox no longer works. You can change it:
This codebase leaves it at the default. Nothing is stored: the token is signed and carries its own
expiry, so there is no reset column to add, no index to maintain and no cleanup job. It also stops
working when the password changes, which means an old link in an old email cannot be replayed after
the account has already been recovered.
Reading it back is one call, and it raises rather than returning nil:
Rescuing InvalidSignature specifically, rather than everything, is what turns a tampered token
into a friendly redirect while leaving real bugs visible.
authenticate_by, and why not find_by then authenticate
has_secure_password gives you authenticate on the instance and User.authenticate_by on the
class. The obvious version:
leaks information through timing. When the address does not exist there is no digest to compare, so
the response comes back measurably faster, and an attacker can enumerate your users by watching the
clock. authenticate_by performs the digest comparison either way, in constant time.
What this codebase does around it
Three decisions in PasswordsController are worth copying.
The notice is the same whether or not an account exists. Saying "no account with that address"
would be friendlier and would also hand an attacker a list of your customers.
This is the point of database-backed sessions. Someone resetting their password is often doing it
because they think they were compromised, and a stolen session cookie that survives a password
reset makes the reset pointless.
The endpoint is rate limited, using the rate_limit macro Rails 8 ships:
has_secure_passwordis one line. It is also the most under-read line in a Rails model, because almost everything it does is invisible until it bites.The full signature is
has_secure_password(attribute = :password, validations: true, reset_token: true). It requires apassword_digestcolumn, and bcrypt in your Gemfile:The validations you did not write
With
validations: true, which is the default, three arrive automatically.Presence on creation. Straightforward, and the reason a factory that forgets a password fails loudly instead of creating a passwordless account.
Length of at most 72 bytes. This one is worth understanding rather than memorising. bcrypt truncates input beyond 72 bytes, so without the validation a user with a 100-character passphrase would have the tail silently ignored, and any password sharing its first 72 bytes would let them in. Note that it is bytes, not characters: a passphrase of emoji or accented text hits the limit sooner than it looks.
Confirmation.
password_confirmationis checked againstpassword, but only when it is non-nil. Leave the field out of your form entirely and the validation never fires. That is the documented behaviour, and it is why a password change form without a confirmation field simply works rather than failing on a blank mismatch.All three can be turned off with
validations: falseif you want complete control. This codebase keeps them and adds one of its own, aPasswordComplexityValidatorthat only fires when a password is actually being set.password_challenge, the attribute almost nobody useshas_secure_passwordalso creates apassword_challengeaccessor. Set it to anything other than nil and it validates against the currently persisted password.That is exactly what you want on a "change your password" form, where the user must prove they know the old one. The hand-rolled version is a manual
user.authenticate(params[:current_password])call in the controller; the built-in version is a validation, so it participates in the normal error rendering with no extra branch.One caveat from the documentation, and it explains the failures people hit with it: the validation relies on dirty tracking through
ActiveModel::Dirty. On a plain Active Record model that is free. On a form object or aActiveModel::Modelwithout dirty tracking defined, the validation fails.This codebase does not use it yet. The password change path goes through the reset token rather than a current-password challenge, which is a defensible choice for a product where most accounts arrive through OAuth or a pay-first checkout and may never have typed a password at all.
The reset token, and its fifteen minutes
With
reset_token: true, again the default, and a model that responds togenerates_token_for, which every Active Record does, you get a password reset token for free.It is valid for 15 minutes by default. That is short, deliberately, and it is a number worth knowing before a user tells you the link in their inbox no longer works. You can change it:
This codebase leaves it at the default. Nothing is stored: the token is signed and carries its own expiry, so there is no reset column to add, no index to maintain and no cleanup job. It also stops working when the password changes, which means an old link in an old email cannot be replayed after the account has already been recovered.
Reading it back is one call, and it raises rather than returning nil:
Rescuing
InvalidSignaturespecifically, rather than everything, is what turns a tampered token into a friendly redirect while leaving real bugs visible.authenticate_by, and why notfind_bythenauthenticatehas_secure_passwordgives youauthenticateon the instance andUser.authenticate_byon the class. The obvious version:leaks information through timing. When the address does not exist there is no digest to compare, so the response comes back measurably faster, and an attacker can enumerate your users by watching the clock.
authenticate_byperforms the digest comparison either way, in constant time.What this codebase does around it
Three decisions in
PasswordsControllerare worth copying.The reset request answers identically either way:
The notice is the same whether or not an account exists. Saying "no account with that address" would be friendlier and would also hand an attacker a list of your customers.
Every session dies with the password:
This is the point of database-backed sessions. Someone resetting their password is often doing it because they think they were compromised, and a stolen session cookie that survives a password reset makes the reset pointless.
The endpoint is rate limited, using the
rate_limitmacro Rails 8 ships:Without it, the reset form is a free email cannon pointed at any address an attacker chooses.