[GH-ISSUE #8904] [Bug]: phoneNumber rate-limit window appears to be treated as milliseconds in plugin and seconds in core on v1.2.12 #28553

Open
opened 2026-04-17 20:00:19 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @devandtravel on GitHub (Apr 1, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8904

Is this suited for github?

  • Yes, this is suited for github

Reproduction

Minimal repro steps

Start an app with better-auth@1.2.12 and phoneNumber() plugin enabled.
Do not override rateLimit.customRules for /phone-number/*.
From the same IP, call POST /api/auth/phone-number/send-otp 10 times.
Wait much longer than 60 seconds.
Call the same endpoint again from the same IP.

Observed result

The request can still return 429 Too Many Requests, as if the rate-limit window is much longer than 60 seconds.

Why I suspect a units mismatch

From the built runtime we observed:

phone plugin rule: window: 60 * 1e3
core limiter logic: windowInMs = window * 1e3
That combination would turn 60s into 60000s.

Current vs. Expected behavior

Current behavior:
On better-auth@1.2.12, the phone-number endpoints appear to hit a much longer rate-limit window than intended.

In our production build, /phone-number/* from the phone plugin appears to register a rule with:

  • window: 60 * 1e3
  • max: 10

But the core rate limiter later computes:

  • windowInMs = window * 1e3

This makes the effective window behave like 60000 seconds (~16.7 hours) instead of 60 seconds.

Observed result:
After 10 requests to POST /api/auth/phone-number/send-otp from the same IP, the 11th request can still return 429 many hours later, which looks inconsistent with a 60-second window.

Expected behavior:
If the intended rule is "10 requests per 60 seconds", then the effective window should be 60 seconds, not ~16.7 hours.

What version of Better Auth are you using?

1.2.12

System info

- Node.js: 22.x (`node:22-alpine` in our Docker image)
- Framework: TanStack Start SSR (`@tanstack/react-start` 1.157.16), React 19, Vite 7
- Adapter: Drizzle + PostgreSQL
- Phone Number plugin enabled: yes

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { betterAuth } from 'better-auth';
import { phoneNumber } from 'better-auth/plugins';

export const auth = betterAuth({
  // adapter config omitted
  plugins: [
    phoneNumber({
      sendOTP: async () => {
        // stub is enough for repro
      },
    }),
  ],
});

Additional context

Could you confirm:

whether this was a bug in older versions,
whether it was fixed in >= 1.5.0,
and whether window is expected to always be expressed in seconds for plugin-level rules too?

Additional context

I also noticed the changelog for v1.5.0 mentions:

"Rate Limiter Improvements"
"Plugin-level rate limit rules"
"Stricter default rate limits: For password reset and phone number verification endpoints"
So this may already have been addressed in later versions, but I could not find a specific issue/PR describing this exact units mismatch.

Originally created by @devandtravel on GitHub (Apr 1, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8904 ### Is this suited for github? - [x] Yes, this is suited for github ### Reproduction Minimal repro steps Start an app with better-auth@1.2.12 and phoneNumber() plugin enabled. Do not override rateLimit.customRules for /phone-number/*. From the same IP, call POST /api/auth/phone-number/send-otp 10 times. Wait much longer than 60 seconds. Call the same endpoint again from the same IP. Observed result The request can still return 429 Too Many Requests, as if the rate-limit window is much longer than 60 seconds. Why I suspect a units mismatch From the built runtime we observed: phone plugin rule: window: 60 * 1e3 core limiter logic: windowInMs = window * 1e3 That combination would turn 60s into 60000s. ### Current vs. Expected behavior Current behavior: On `better-auth@1.2.12`, the phone-number endpoints appear to hit a much longer rate-limit window than intended. In our production build, `/phone-number/*` from the phone plugin appears to register a rule with: - `window: 60 * 1e3` - `max: 10` But the core rate limiter later computes: - `windowInMs = window * 1e3` This makes the effective window behave like `60000 seconds` (~16.7 hours) instead of `60 seconds`. Observed result: After 10 requests to `POST /api/auth/phone-number/send-otp` from the same IP, the 11th request can still return `429` many hours later, which looks inconsistent with a 60-second window. Expected behavior: If the intended rule is "10 requests per 60 seconds", then the effective window should be 60 seconds, not ~16.7 hours. ### What version of Better Auth are you using? 1.2.12 ### System info ```bash - Node.js: 22.x (`node:22-alpine` in our Docker image) - Framework: TanStack Start SSR (`@tanstack/react-start` 1.157.16), React 19, Vite 7 - Adapter: Drizzle + PostgreSQL - Phone Number plugin enabled: yes ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth'; import { phoneNumber } from 'better-auth/plugins'; export const auth = betterAuth({ // adapter config omitted plugins: [ phoneNumber({ sendOTP: async () => { // stub is enough for repro }, }), ], }); ``` ### Additional context Could you confirm: whether this was a bug in older versions, whether it was fixed in >= 1.5.0, and whether window is expected to always be expressed in seconds for plugin-level rules too? Additional context I also noticed the changelog for v1.5.0 mentions: "Rate Limiter Improvements" "Plugin-level rate limit rules" "Stricter default rate limits: For password reset and phone number verification endpoints" So this may already have been addressed in later versions, but I could not find a specific issue/PR describing this exact units mismatch.
GiteaMirror added the securitybug labels 2026-04-17 20:00:19 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 1, 2026):

Hi @devandtravel! I'm Dosu and I’m helping the better-auth team.

You're right — this was a confirmed units mismatch bug. The phone-number plugin defined its rate limit window as window: 60 * 1000 (treating it as milliseconds), but the core rate limiter expects window in seconds and internally multiplies by 1000 to get milliseconds [1]. That double multiplication turned a 60-second window into ~16.7 hours.

This was fixed in PR #8006, which changed the plugin's configuration from window: 60 * 1000 to window: 60 [2]. The current code correctly reads [3]:

rateLimit: [
  {
    pathMatcher(path) {
      return path.startsWith("/phone-number");
    },
    window: 60,  // seconds
    max: 10,
  },
]

That same PR also hardened default rate limits for other sensitive endpoints (/request-password-reset, /send-verification-email, /forget-password/*, etc.) and added a warning when no IP address is found for rate limiting [2].

To answer your specific questions:

  • Was this a bug in older versions? Yes, it existed in v1.2.12 and likely earlier versions that included the phone-number plugin.
  • Was it fixed in >= 1.5.0? Yes — the fix (PR #8006) was merged on February 16, 2026, so any release after that includes it.
  • Is window always in seconds for plugin-level rules? Yes — window should always be expressed in seconds across all rate limit rules (plugin-level or otherwise). The core rate limiter handles the conversion to milliseconds internally [1].

Upgrading to a version that includes PR #8006 will resolve this for you.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:4173362798 --> @dosubot[bot] commented on GitHub (Apr 1, 2026): <!-- Greeting --> Hi @devandtravel! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> You're right — this was a confirmed units mismatch bug. The phone-number plugin defined its rate limit window as `window: 60 * 1000` (treating it as milliseconds), but the core rate limiter expects `window` in **seconds** and internally multiplies by 1000 to get milliseconds [[1]](https://github.com/better-auth/better-auth/blob/51518c372dd328041f772db3fd5d7f2b3cb4f3de/packages/better-auth/src/api/rate-limiter/index.ts#L25). That double multiplication turned a 60-second window into ~16.7 hours. This was fixed in [PR #8006](https://github.com/better-auth/better-auth/pull/8006), which changed the plugin's configuration from `window: 60 * 1000` to `window: 60` [[2]](https://github.com/better-auth/better-auth/pull/8006). The current code correctly reads [[3]](https://github.com/better-auth/better-auth/blob/51518c372dd328041f772db3fd5d7f2b3cb4f3de/packages/better-auth/src/plugins/phone-number/index.ts#L71-L79): ```typescript rateLimit: [ { pathMatcher(path) { return path.startsWith("/phone-number"); }, window: 60, // seconds max: 10, }, ] ``` That same PR also hardened default rate limits for other sensitive endpoints (`/request-password-reset`, `/send-verification-email`, `/forget-password/*`, etc.) and added a warning when no IP address is found for rate limiting [[2]](https://github.com/better-auth/better-auth/pull/8006). To answer your specific questions: - **Was this a bug in older versions?** Yes, it existed in v1.2.12 and likely earlier versions that included the phone-number plugin. - **Was it fixed in >= 1.5.0?** Yes — the fix (PR #8006) was merged on February 16, 2026, so any release after that includes it. - **Is `window` always in seconds for plugin-level rules?** Yes — `window` should always be expressed in seconds across all rate limit rules (plugin-level or otherwise). The core rate limiter handles the conversion to milliseconds internally [[1]](https://github.com/better-auth/better-auth/blob/51518c372dd328041f772db3fd5d7f2b3cb4f3de/packages/better-auth/src/api/rate-limiter/index.ts#L25). Upgrading to a version that includes PR #8006 will resolve this for you. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=41c64950-637d-436d-8376-bf4f01358df8&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=41c64950-637d-436d-8376-bf4f01358df8&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=41c64950-637d-436d-8376-bf4f01358df8&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=41c64950-637d-436d-8376-bf4f01358df8&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=41c64950-637d-436d-8376-bf4f01358df8&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=41c64950-637d-436d-8376-bf4f01358df8&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=41c64950-637d-436d-8376-bf4f01358df8&feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27re%20right%20%E2%80%94%20this%20was%20a%20confirmed%20units%20mismatch%20bug.%20The%20phone-number%20plugin%20defined%20its%20rate%20limit%20window%20as%20%60window%3A%2060%20%2A%201000%60%20%28treating%20it%20as%20milliseconds%29%2C%20but%20the%20core%20rate%20limiter%20expects%20%60window%60%20in%20%2A%2Aseconds%2A%2A%20and%20internally%20multiplies%20by%201000%20to%20get%20milliseconds%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/51518c372dd328041f772db3fd5d7f2b3cb4f3de/packages/better-auth/src/api/rate-limiter/index.ts%23L25%29.%20That%20double%20multiplication%20turned%20a%2060-second%20window%20into%20~16.7%20hours.%0A%0AThis%20was%20fixed%20in%20%5BPR%20%238006%5D%28https%3A//github.com/better-auth/better-auth/pull/8006%29%2C%20which%20changed%20the%20plugin%27s%20configuration%20from%20%60window%3A%2060%20%2A%201000%60%20to%20%60window%3A%2060%60%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/8006%29.%20The%20current%20code%20correctly%20reads%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/51518c372dd328041f772db3fd5d7f2b3cb4f3de/packages/better-auth/src/plugins/phone-number/index.ts%23L71-L79%29%3A%0A%0A%60%60%60typescript%0ArateLimit%3A%20%5B%0A%20%20%7B%0A%20%20%20%20pathMatcher%28path%29%20%7B%0A%20%20%20%20%20%20return%20path.startsWith%28%22/phone-number%22%29%3B%0A%20%20%20%20%7D%2C%0A%20%20%20%20window%3A%2060%2C%20%20//%20seconds%0A%20%20%20%20max%3A%2010%2C%0A%20%20%7D%2C%0A%5D%0A%60%60%60%0A%0AThat%20same%20PR%20also%20hardened%20default%20rate%20limits%20for%20other%20sensitive%20endpoints%20%28%60/request-password-reset%60%2C%20%60/send-verification-email%60%2C%20%60/forget-password/%2A%60%2C%20etc.%29%20and%20added%20a%20warning%20when%20no%20IP%20address%20is%20found%20for%20rate%20limiting%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/8006%29.%0A%0ATo%20answer%20your%20specific%20questions%3A%0A%0A-%20%2A%2AWas%20this%20a%20bug%20in%20older%20versions%3F%2A%2A%20Yes%2C%20it%20existed%20in%20v1.2.12%20and%20likely%20earlier%20versions%20that%20included%20the%20phone-number%20plugin.%0A-%20%2A%2AWas%20it%20fixed%20in%20%3E%3D%201.5.0%3F%2A%2A%20Yes%20%E2%80%94%20the%20fix%20%28PR%20%238006%29%20was%20merged%20on%20February%2016%2C%202026%2C%20so%20any%20release%20after%20that%20includes%20it.%0A-%20%2A%2AIs%20%60window%60%20always%20in%20seconds%20for%20plugin-level%20rules%3F%2A%2A%20Yes%20%E2%80%94%20%60window%60%20should%20always%20be%20expressed%20in%20seconds%20across%20all%20rate%20limit%20rules%20%28plugin-level%20or%20otherwise%29.%20The%20core%20rate%20limiter%20handles%20the%20conversion%20to%20milliseconds%20internally%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/51518c372dd328041f772db3fd5d7f2b3cb4f3de/packages/better-auth/src/api/rate-limiter/index.ts%23L25%29.%0A%0AUpgrading%20to%20a%20version%20that%20includes%20PR%20%238006%20will%20resolve%20this%20for%20you.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/8904)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28553