In September’s auth milestone, email OTP, password reset, and Google login all wrapped up together. Among the things the first review round turned up, one was related to rate limiting, and I’m writing it down because it’s nothing like “just configuring a few numbers.”
Look at the code order first
better-auth’s email-otp plugin first calls resolveOTP (email-otp/routes.mjs:31) in the endpoint, unconditionally inserting a code row; the sendVerificationOTP callback that sends the email only runs after that. On consumption, consumeVerificationValue (internal-adapter.mjs:818-860) only ever recognizes “the latest row for that identifier,” and on consumption it deleteMany all rows.
The old code hung the rate-limit check inside the send-email callback, i.e. after that insert. Once it tripped:
resolveOTPhad already written a new code row;- The callback checked the rate limit, refused to send, and that row never reached anyone’s inbox;
- The user entered the old code from the email in hand; it matched the latest row, and
INVALID_OTP; - Both rows were wiped together.
The user was stuck: the code in their inbox was dead, no new code could be obtained within the cooldown window, and the error message said “invalid code,” putting the blame on the user.
Move it before the insert
The fix is to block at hooks.before. The dispatch supports a before hook returning an object without the context key to short-circuit (the end of runBeforeHooks + else if (before) in dispatchAuthEndpoint), the endpoint handler doesn’t run at all, and that row never gets written.
All three invariants were kept, each with its own assertion:
- The rate-limited response is byte-for-byte identical to the “email not registered” response (status code + body + content-type all compared);
- Rate-limited requests send no email;
- The first request in the window still sends exactly one email. With the check running before the insert, the old -1 compensation in
assertOtpRateExcludingCurrentwas deleted too.
The rate-limit layer itself turns into a registration oracle
The second version’s problem was subtler. resolveOTP leaves a persistent verification row for a registered email, but better-auth immediately deletes the just-inserted row for an unregistered email (enumeration defense — both request-password-reset and forget-password/email-otp do this). If a rate-limit hit threw 429, then hammering the same address twice would directly tell an attacker whether that email has an account, depending on whether the status code sequence was (200,429) or (200,200).
Before the identifier format was fixed, the counter was mute — it neither protected nor leaked; after the fix, the counter actually started counting registered emails for the first time, and the side effect became observable.
| Path | On rate-limit hit | Rationale |
|---|---|---|
| Password reset | Silently skip sending, endpoint still returns 200 | “Rate-limited” and “email doesn’t exist” must be indistinguishable |
| Email verification | 429 | The sign-up endpoint already leaks the same fact once via USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, and /verify's resend button needs real feedback |
One footnote to this table: whoever one day plugs the sign-up leak must at the same time change the email-verification 429 to a silent short-circuit, otherwise it’s only half plugged.
Rejected requests must not leave a row
The hourly quota counts rows in the verification table. In the old implementation, rejected requests also left a row, so an attacker could fill a victim’s quota with five straight hits — only one email actually went out, but the person was locked out for an hour.
With the check moved before resolveOTP, rejections leave no row. There’s a dedicated regression case:
expect(await countOtpRows('forget-password', email)).toBe(1)Reverting would count to 2. What the quota should count is “emails actually sent.”
Rescuing a one-shot path
The same round also had the squatting arbitration. When Google login hits an unverified email, it deletes the squatter’s password in databaseHooks.account.create.after. The function had only been reasoned through for “a crash between the two deletes,” never for “any throw anywhere in the function body”:
create.after → queueAfterTransactionHook (with-hooks.mjs:33 didn’t give onError, re-throws as-is) → createWithHooks → linkAccount → generic catch, returns unable to link account. And the account row is already committed; a throw can’t undo it.
The user sees one login failure, retries → takes the already-linked branch (link-account.mjs 135–181); that branch doesn’t trigger the hook but does set emailVerified to true. Endgame: the email is verified, the victim logged in with Google, the squatter’s password still works as usual, and it’s indistinguishable in hindsight — it looks exactly like “an already-verified account hooked up to Google.”
The fix is to wrap the whole function body in try/catch, swallow it, don’t rethrow, and just log a single [auth] 抢注仲裁失败 … entry with the userId. The reason for not rethrowing is written in the comment: throwing can’t undo the already-committed link, it only adds one more inexplicable login failure, and the retry takes the branch that doesn’t even log, which buries the hole deeper. The test mocks db.transaction to make it throw, asserting the return is false, the log has the userId, and the password is still there.
“Please verify your email first” needs to actually be clickable
Someone else in the same round hit another one: they registered, closed the tab, came back the next day to post, and got stopped by a 403 saying “Please verify your email before posting content.” — a dead end with nothing clickable in it, and site-wide /verify only links out from the registration page and the Google button. Even if you found /verify, the page opens by announcing “a verification code has been sent to your email,” but that page never sends a code on load — the line is a lie.
We changed email_unverified to render as "copy + a link to /verify", across eight user-side render points. The copy in /verify became “Enter the 6-digit code you received… if it didn’t arrive, hit the button below to request one”, updated in all three languages; the resend button moved from ghost to outline. No auto-send on page entry — that would burn the per-email hourly quota on every refresh.
An Empty String Is Not “Not Configured”
There’s another of the same kind on the deployment side. The docs say in four places that “omitting the email variables doesn’t error, it just behaves wrong; if MAIL_TRANSPORT isn’t passed it defaults to console.” That’s false: Compose substitutes host variables that were never set with an empty string, and ?? 'console' and .default() only accept undefined; parseMailEnv({ MAIL_TRANSPORT: '' }) throws outright, and the symptom is that the api container won’t start.
Crashing is the better behavior, so we didn’t grant empty strings an exemption — instead we wrote the contract down clearly: this group of variables must exist and be non-empty, and if the container won’t start, check the email config first. Two assertions in mail/config.test.ts pin it down.
The test and e2e scripts, meanwhile, hard-prefix MAIL_TRANSPORT=console. Shell environment variables take precedence over --env-file, so without that prefix, once you’ve set MAIL_TRANSPORT to resend locally: a single test run fires off a batch of real emails to actual inboxes, and e2e fails outright because it can’t read the verification code from stdout.
Wrapping Up
Rate limiting isn’t a set of numbers, it’s a position in the write order. Get the position wrong and what it stops isn’t the attacker — it’s the email in the user’s hands.
Later, all of these became assertions: the bytes of a 429, only one email sent, no row left behind on rejection, userId preserved on failure, empty string must crash. Individually they look trivial, but only together do they show that rate limiting ended up in the right place.
