Rails 8 password reset: the token is the bcrypt salt
September 15, 2026
More on Rails 8 authentication
- 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_password: everything one line gives you
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_tokencolumn, noreset_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
PasswordsControllercallsUser.find_by_password_reset_token!, and grepping theUsermodel forpassword_resetfinds no such method. It comes from one line:has_secure_passwordtakes areset_token:argument that defaults totrue, and when it is on and the object responds togenerates_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:What makes the link single-use
The payload is the part worth reading the source for. Active Model defines the token like this:
The payload is the last ten characters of the bcrypt salt.
generates_token_forembeds 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:
Two finders, one raises
The bang version raises the same
InvalidSignaturefor an expired token and for a tampered one, which is why the controller rescues exactly that and nothing else: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
The
ifhas noelse, 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_laterenqueues rather than sending, so the known-address branch does not sit waiting on SMTP while the unknown one returns instantly. A synchronousdeliver_nowwould restore the oracle through the stopwatch.The throttle on the same action matters for a second reason:
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
@user.sessions.destroy_allis 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
Sessionrecord 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
elsebranch 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.