LaunchKit

Rails 8 password reset: the token is the bcrypt salt

September 15, 2026

A Rails password reset is the rare feature where the framework hands you more than it looks like: four controller actions, no migration, and nothing to clean up afterwards. The interesting part is what is not there: no reset_token column, no reset_sent_at, no nightly job sweeping expired rows. All of it is derived.

The authentication hub covers getting in. This is the door for someone who cannot.

The token nobody declared

PasswordsController calls User.find_by_password_reset_token!, and grepping the User model for password_reset finds no such method. It comes from one line:

class User < ApplicationRecord
  has_secure_password
end

has_secure_password takes a reset_token: argument that defaults to true, and when it is on and the object responds to generates_token_for, which Active Records do, it defines the token and both finders for you.

The expiry is 15 minutes, from ActiveModel::SecurePassword::DEFAULT_RESET_TOKEN_EXPIRES_IN, and it takes an argument when 15 minutes is wrong for your product:

has_secure_password reset_token: { expires_in: 1.hour }

The payload is the part worth reading the source for. Active Model defines the token like this:

generates_token_for :"#{attribute}_reset", expires_in: reset_token_expires_in do
  public_send(:"#{attribute}_salt")&.last(10)
end

The payload is the last ten characters of the bcrypt salt.

generates_token_for embeds the block's return value in the signed token and compares it against a fresh evaluation at lookup time. Setting a new password generates a new bcrypt hash, which contains a new salt, so the payload no longer matches and the token stops verifying. The link is spent the moment it is used, and every other outstanding link for that user is spent with it.

No column, no used_at, no cleanup. The thing that invalidates the token is the thing the token exists to change.

Two consequences follow, and both are useful:

  • A user who requests three resets and clicks the oldest one still gets in, because all three carry the same salt. They are not a queue, they are three copies of one key.
  • A user whose password was changed by any other route, an admin action, a console session, finds their pending reset link dead. That is correct and it surprises people.

Two finders, one raises

User.find_by_password_reset_token(token)   # => nil once expired
User.find_by_password_reset_token!(token)  # => raises ActiveSupport::MessageVerifier::InvalidSignature

The bang version raises the same InvalidSignature for an expired token and for a tampered one, which is why the controller rescues exactly that and nothing else:

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

Telling the two apart would mean telling the user which kind of bad token they hold, and neither answer helps them. One message covers both and the user does the same thing either way: ask for a new link.

The two lines that decide whether the form leaks your user list

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 if has no else, and the redirect is outside it. An address that exists gets an email. An address that does not gets nothing. Both get the same page and the same sentence.

Write it the obvious way instead, with "we could not find that account" on the miss, and the form becomes an oracle: anyone can ask it whether a given address has an account here, one address at a time, as fast as they like. For most products that is a privacy leak. For some it is worse, because membership in the product is itself sensitive.

Timing is the other half of that answer, and this code is honest about it by accident: deliver_later enqueues rather than sending, so the known-address branch does not sit waiting on SMTP while the unknown one returns instantly. A synchronous deliver_now would restore the oracle through the stopwatch.

The throttle on the same action matters for a second reason:

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

Without it, an endpoint that sends mail to an address supplied in the request is a free mail cannon pointed at anybody. Rails 8 rate_limit covers what that macro does and does not count.

Resetting is also a logout

def update
  if @user.update(params.permit(:password, :password_confirmation))
    @user.sessions.destroy_all
    redirect_to new_session_path, notice: t("auth.passwords.flash.updated")
  else
    redirect_to edit_password_path(params[:token]), alert: t("auth.passwords.flash.mismatch")
  end
end

@user.sessions.destroy_all is one line and it is the reason the feature exists.

Somebody resetting their password has often lost control of the account, and leaving the attacker's session alive while handing the owner a new password solves nothing. Rails does not do this for you. The generated Session record is what makes it possible at all: Rails session expiry works through why that row exists and why it never ends on its own, and this is the payoff for keeping it.

Note the redirect afterwards goes to sign-in, not to the dashboard. The reset just destroyed every session including the one performing it, so there is nobody to send to a dashboard.

The else branch redirects back to the edit form with the token, which is only possible because the token is still valid: the password did not change, so the salt did not change. The mechanism from the top of this page is what makes a failed confirmation retryable.

What this does not cover

Requiring the current password on a change initiated by a signed-in user, which is a different screen with a different threat model. Notifying the user by mail that their password changed, which is worth doing and is not in the generated flow. And password strength rules, which has_secure_password covers where the validations live.

More on Rails 8 authentication

← All Rails 8 authentication articles