Skip to content

Commit 89dd67c

Browse files
committed
fix(auth): move the OTP send guard to hooks.before (admission control)
Found dogfooding the live flow: better-auth's /phone-number/send-otp stores the fresh code BEFORE invoking sendOTP, so a cooldown rejection thrown from the callback still rotated - and thereby invalidated - the previously delivered code. A user hitting resend during the cooldown (or an attacker spamming the endpoint) voided the valid OTP every time. The per-number cooldown/hourly-cap check now runs in the hooks.before middleware (assertPhoneOtpSendAllowed) for /phone-number/send-otp and /phone-number/request-password-reset - rejected requests never reach the endpoint handler, so the stored code survives. The sendOTP callback only delivers. Behaviour verified end-to-end against a live server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LXUXU66dBaP3SSG4ZVtuH
1 parent 44009d7 commit 89dd67c

2 files changed

Lines changed: 72 additions & 30 deletions

File tree

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,29 +1143,42 @@ describe('AuthManager', () => {
11431143
expect(sms.sent[0].templateParams).toEqual({ code: '123456' });
11441144
});
11451145

1146-
it('enforces the per-number cooldown (TOO_MANY_REQUESTS, no second SMS)', async () => {
1146+
it('enforces the per-number cooldown at ADMISSION (before-hook), not in sendOTP', async () => {
11471147
const { manager, opts } = await bootOtp();
11481148
const sms = fakeSms();
11491149
manager.setSmsService(sms.service);
11501150

1151-
await opts.sendOTP({ phoneNumber: PHONE, code: '111111' });
1152-
await expect(opts.sendOTP({ phoneNumber: PHONE, code: '222222' }))
1151+
// Admission guard: first request per number passes, immediate second 429s.
1152+
await manager.assertPhoneOtpSendAllowed(PHONE);
1153+
await expect(manager.assertPhoneOtpSendAllowed(PHONE))
11531154
.rejects.toThrow(/Too many verification codes/);
1154-
expect(sms.sent).toHaveLength(1);
1155+
1156+
// The sendOTP callback itself must NOT re-guard: better-auth stores the
1157+
// fresh code BEFORE invoking it, so a rejection at that point would
1158+
// still rotate (void) the previously delivered code. Delivery always
1159+
// proceeds once a request was admitted.
1160+
await opts.sendOTP({ phoneNumber: PHONE, code: '111111' });
1161+
await opts.sendOTP({ phoneNumber: PHONE, code: '222222' });
1162+
expect(sms.sent).toHaveLength(2);
11551163
});
11561164

1157-
it('sendPasswordResetOTP shares the same guarded SMS path (cross-flow budget)', async () => {
1158-
const { manager, opts } = await bootOtp();
1159-
const sms = fakeSms();
1160-
manager.setSmsService(sms.service);
1165+
it('the admission budget spans both flows (send-otp + request-password-reset)', async () => {
1166+
const { manager } = await bootOtp();
1167+
manager.setSmsService(fakeSms().service);
11611168

1162-
await opts.sendPasswordResetOTP({ phoneNumber: PHONE, code: '333333' });
1163-
expect(sms.sent[0].body).toContain('333333');
1164-
// The cooldown spans both flows — a reset send blocks an immediate sign-in send.
1165-
await expect(opts.sendOTP({ phoneNumber: PHONE, code: '444444' }))
1169+
// One admitted send (whatever the flow) blocks an immediate second one.
1170+
await manager.assertPhoneOtpSendAllowed(PHONE);
1171+
await expect(manager.assertPhoneOtpSendAllowed(PHONE))
11661172
.rejects.toThrow(/Too many verification codes/);
11671173
});
11681174

1175+
it('admission is a no-op while OTP is undeliverable (sendOTP fails loudly instead)', async () => {
1176+
const { manager } = await bootOtp();
1177+
// No SMS service wired — the guard must not consume budget or throw.
1178+
await manager.assertPhoneOtpSendAllowed(PHONE);
1179+
await manager.assertPhoneOtpSendAllowed(PHONE);
1180+
});
1181+
11691182
it('surfaces a failed SMS delivery WITHOUT the code in the error', async () => {
11701183
const { manager, opts } = await bootOtp();
11711184
const sms = fakeSms({ failed: true });
@@ -1175,13 +1188,11 @@ describe('AuthManager', () => {
11751188
.rejects.toSatisfy((e: Error) => /provider down/.test(e.message) && !e.message.includes('555555'));
11761189
});
11771190

1178-
it('honours phoneOtp knobs (cooldown off ⇒ back-to-back sends allowed)', async () => {
1179-
const { manager, opts } = await bootOtp({ phoneOtp: { cooldownSeconds: 0, maxPerHour: 0 } });
1180-
const sms = fakeSms();
1181-
manager.setSmsService(sms.service);
1182-
await opts.sendOTP({ phoneNumber: PHONE, code: '111111' });
1183-
await opts.sendOTP({ phoneNumber: PHONE, code: '222222' });
1184-
expect(sms.sent).toHaveLength(2);
1191+
it('honours phoneOtp knobs (cooldown off ⇒ back-to-back admissions allowed)', async () => {
1192+
const { manager } = await bootOtp({ phoneOtp: { cooldownSeconds: 0, maxPerHour: 0 } });
1193+
manager.setSmsService(fakeSms().service);
1194+
await manager.assertPhoneOtpSendAllowed(PHONE);
1195+
await manager.assertPhoneOtpSendAllowed(PHONE);
11851196
});
11861197

11871198
it('features.phoneNumberOtp requires plugin + deliverable SMS', async () => {

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,23 @@ export class AuthManager {
818818
// sees `userCount > 0` and the toggle is enforced again.
819819
hooks: {
820820
before: createAuthMiddleware(async (ctx: any) => {
821+
// ── #2780: per-number OTP send guard (admission control) ─────
822+
// MUST run BEFORE the phone-number endpoints: better-auth's
823+
// send-otp handler stores a fresh code and only THEN invokes
824+
// `sendOTP` — a guard that throws inside the callback would
825+
// still rotate (invalidate) the previously delivered code, so a
826+
// blocked resend (or an attacker spamming the endpoint) could
827+
// keep voiding the user's valid OTP. Rejecting here leaves the
828+
// stored code untouched. Applies uniformly to registered and
829+
// unregistered numbers (no account-existence oracle).
830+
if (
831+
ctx?.path === '/phone-number/send-otp' ||
832+
ctx?.path === '/phone-number/request-password-reset'
833+
) {
834+
const phone = typeof ctx?.body?.phoneNumber === 'string' ? ctx.body.phoneNumber : '';
835+
await this.assertPhoneOtpSendAllowed(phone);
836+
}
837+
821838
// ── ADR-0069 D1: password complexity (validator) ────────────
822839
// better-auth enforces only min/max length; class-mix is custom.
823840
// Runs on the password-mutating endpoints; reads the candidate from
@@ -2084,15 +2101,36 @@ export class AuthManager {
20842101
return this._otpSendGuard;
20852102
}
20862103

2104+
/**
2105+
* #2780 — admission check for the OTP send endpoints, called from the
2106+
* `hooks.before` middleware (see the `/phone-number/*` branch there for
2107+
* why it cannot live in the `sendOTP` callback). Consumes one unit of the
2108+
* per-number budget and throws TOO_MANY_REQUESTS when the cooldown /
2109+
* hourly cap is exhausted. No-op while OTP is undeliverable — the send
2110+
* callback then fails loudly with NOT_SUPPORTED instead.
2111+
*/
2112+
async assertPhoneOtpSendAllowed(phone: string): Promise<void> {
2113+
if (!phone || !this.isPhoneOtpDeliverable()) return;
2114+
const decision = await this.getOtpSendGuard().checkAndRecord(phone);
2115+
if (!decision.ok) {
2116+
const { APIError } = await import('better-auth/api');
2117+
throw new APIError('TOO_MANY_REQUESTS', {
2118+
message: `Too many verification codes requested for this phone number. Retry in ${decision.retryAfterSeconds ?? 60}s.`,
2119+
});
2120+
}
2121+
}
2122+
20872123
/**
20882124
* #2780 — deliver a phone OTP through the SMS service.
20892125
*
20902126
* Security posture (all named requirements of #2780):
20912127
* - No SMS service ⇒ throw NOT_SUPPORTED (loud, like the pre-SMS wiring).
2092-
* - Per-number cooldown + hourly cap BEFORE the provider is called
2093-
* (SMS-pumping cost abuse); violations throw TOO_MANY_REQUESTS, which
2094-
* /phone-number/send-otp surfaces as an honest 429 while the
2095-
* request-password-reset route logs-and-200s (no enumeration oracle).
2128+
* - The per-number cooldown + hourly cap live in the `hooks.before`
2129+
* admission check, NOT here: better-auth stores the fresh code before
2130+
* invoking this callback, so a rejection at this point would still
2131+
* rotate (invalidate) the previously delivered code — letting a
2132+
* blocked resend or an endpoint-spamming attacker void a user's valid
2133+
* OTP. See the `/phone-number/*` branch in `hooks.before`.
20962134
* - The code is embedded in the message body ONLY — it must never reach
20972135
* a log line or an error message (the SmsService logs masked numbers
20982136
* and statuses, never bodies).
@@ -2108,13 +2146,6 @@ export class AuthManager {
21082146
'Phone sign-in is password-based (POST /sign-in/phone-number).',
21092147
);
21102148
}
2111-
const decision = await this.getOtpSendGuard().checkAndRecord(phone);
2112-
if (!decision.ok) {
2113-
const { APIError } = await import('better-auth/api');
2114-
throw new APIError('TOO_MANY_REQUESTS', {
2115-
message: `Too many verification codes requested for this phone number. Retry in ${decision.retryAfterSeconds ?? 60}s.`,
2116-
});
2117-
}
21182149
const otpCfg = this.config.phoneOtp ?? {};
21192150
const minutes = Math.max(1, Math.round((otpCfg.expiresIn ?? 300) / 60));
21202151
const result = await sms.send({

0 commit comments

Comments
 (0)