Your password-reset form is a free email cannon
The password-reset form is probably the least interesting endpoint you own. It is also the only one an attacker can point at anyone. Ours had no per-email rate limit for a while, and the reason that matters is not the reason you would guess.
The reflex, when you hear "unauthenticated endpoint, no rate limit," is to think about brute force. But there is nothing to brute force on a reset request. You are not guessing a password. You submit an email address and the system sends a link. Hammering it does not get you into an account. So who gets hurt?
The victim is a third party. Type someone else's address, [email protected], hit send, repeat. Every request sends a real email to a real inbox you do not control. You have conscripted our reset form into a mailbomb aimed at somebody who never signed up for anything. And because the mail comes from us, the damage lands in two places at once. The victim's inbox fills up. And our sending reputation takes the hit, because a flood of unwanted mail from our domain is exactly what mailbox providers watch for. Enough of it and our legitimate mail, verification links, actual password resets, starts landing in spam for every real customer we have. An unauthenticated endpoint, with no credentials and no cost to the attacker, degrades a resource all of our users share.
So you rate-limit it. Per email address, so one target cannot be flooded. Easy. Except the obvious implementation quietly opens a different hole.
If you only apply the limit, or only do the work, when the address actually has an account, then the endpoint behaves differently for real accounts than for made-up ones. An attacker who can see that difference, in the response, the status code, or just the timing, now has an oracle for which email addresses are registered with you. Reset forms are a classic account-enumeration leak for exactly this reason: the naive fix for the mailbomb creates the enumeration bug.
The real fix has to do both things at once, and the two halves are separate. First, rate-limit on the normalized email address regardless of whether an account exists. The limit key is the address itself, lowercased and trimmed so Victim@ and victim@ share one bucket, and the counter is applied before you have looked anything up. That counter has to live in a durable, shared store, not in a single process's memory, or it evaporates on a restart and does nothing across multiple instances. Second, respond identically in every case: the same status, the same "if that address has an account, we have sent a link" message, the same timing shape, whether or not the address is one of yours. You still only actually send mail when there is an account behind it. But nothing an outsider can observe changes based on that secret. The rate limit protects the third party; the constant response protects the fact of who has an account.
The general lesson reaches past password resets. Any unauthenticated endpoint that causes a real-world side effect, sending an email, sending an SMS, calling a webhook, is not merely part of your attack surface. It is a weapon aimed at whoever is on the receiving end, and the bill for firing it lands on your reputation, not the attacker's. Rate-limit by the thing being acted on, which is the recipient, not the caller. And do it without letting the presence or absence of secret state change what an observer sees.
The password-reset form feels like plumbing. It is the one piece of plumbing that will happily spray anyone in the world on command. Meter it accordingly.
Metering an unauthenticated side-effect endpoint without leaking who has an account is fiddlier than it looks. Authagonal ships the reset flow already rate-limited and enumeration-safe, so your sender reputation is never one curl loop away from ruin.