Recovery and operations
Build password recovery
Create an enumeration-resistant reset flow with hashed single-use tokens, normal password policy, session invalidation, and user notification.
“Forgot your password?” is a second login system. It lets someone into an account without the password, and it’s often the easiest way around a well-built login page. We build it with the same care.
The request side stays boring
The public response never changes:
POST /forgot-password
→ "If an account exists, we sent instructions."
Same status, same body, similar timing, whether or not the address exists. That closes the enumeration hole.
It does not close the abuse hole. An endpoint that sends an email on every request is a spam cannon pointed at your users. Rate-limit it by account and network signals, as we did for login.
The token
The reset token is random, short-lived (15 to 60 minutes), single-use, and bound to one user and one purpose. Store its hash, never the raw value. The raw token goes in one HTTPS link.
When the user submits the new password, consume the token and replace the password hash in one transaction. If those two writes commit separately, two concurrent submissions with the same link can both succeed.
Build the link from a known origin
Never build the link from the incoming Host header. If your app constructs https://${host}/reset?token=... from it, an attacker sends a request with Host: evil.example and the victim receives a real token pointing at the attacker’s domain. That’s host header injection. Read the origin from configuration, or check it against an allowlist.
Keep the token off the wire
Keep third-party scripts off the reset page. Send Referrer-Policy: no-referrer so the URL isn’t leaked to external resources. Make sure request logs don’t capture query strings. Once the server accepts the token, replace the visible URL with history.replaceState.
After the reset
Apply the normal password policy, and don’t send a new password by email.
Don’t log the user in automatically, make them log in with the new password. Revoke every existing session, so a thief who already had one is kicked out the moment the real owner resets. Send a notification explaining how to report a reset they didn’t request, with no secrets in it.
When the email provider is down
Decide what happens explicitly. Don’t say “we sent instructions” while dropping the request. Queue a delivery job and retry, or return a generic retryable error. Either way, don’t leak whether the account exists.
Try this: write an integration test for the whole flow on the Books API. Compare the responses for an unknown and a known address byte for byte. Send a forged Host header and check the generated link. Try an expired token, a reused token, and two concurrent submissions. Then the final assertion: after the transaction commits, the old password fails at login and every old session cookie returns 401.
Lesson completed