has_secure_password: everything one line gives you
September 06, 2026
More on Rails 8 authentication
- Rails 8 password reset: the token is the bcrypt salt
- Rails 8 rate_limit: throttling sign-in without a gem
- Rails session expiry: the cookie store, the Session row, and the timeout neither gives you
- Rails JWT API authentication with ActionController::API
- Email confirmation in Rails 8, without a confirmation column
- Rails 8 native authentication vs Devise
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. If you are still deciding whether to use Rails authentication at all, the comparison with Devise is the page to read first; this one assumes you have chosen.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
has_secure_passwordwrites for youWith
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
has_secure_passwordThree 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.