LaunchKit

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:

has_secure_password reset_token: { expires_in: 1.hour }

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:

def set_user_by_token
  @user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
  redirect_to new_password_path, alert: t("auth.passwords.flash.invalid_token")
end

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:

user = User.find_by(email_address: params[:email_address])
user&.authenticate(params[:password])

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 reset request answers identically either way:

def create
  if user = User.find_by(email_address: params[:email_address])
    PasswordsMailer.reset(user).deliver_later
  end

  redirect_to new_session_path, notice: t("auth.passwords.flash.sent")
end

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:

if @user.update(params.permit(:password, :password_confirmation))
  @user.sessions.destroy_all

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:

rate_limit to: 10, within: 3.minutes, only: :create, with: -> { rate_limited }

Without it, the reset form is a free email cannon pointed at any address an attacker chooses.

More on Rails 8 authentication

← All Rails 8 authentication articles