[GH-ISSUE #6742] Rate limit defaults are being ignored #27933

Closed
opened 2026-04-17 19:13:21 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @danielfinke on GitHub (Dec 13, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6742

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Enable options.rateLimit
  2. Use /sign-in/email-otp

Current vs. Expected behavior

Current behavior

The route-specific max of 3 reqs per 60s is ignored

Expected behavior

The client gets rate limited on the 4th req within 60s.

If you log the context path here, the path always has a trailing slash. Not sure when that started happening. But in plugin after hooks for example, the ctx.path doesn't have the trailing slash and so is fine by that point.

I wasn't sure whether the appropriate change here was to update the hardcoded pathMatcher paths accordingly, or if the path should be normalized ahead of time if there could be other implications of the path having the trailing slash. Let me know what the right change is and I'd be happy to submit a PR.

What version of Better Auth are you using?

1.4.4

System info

Better Auth System Information

==================================================

🖥️  System Information:
  platform: darwin
  arch: arm64
  version: Darwin Kernel Version 25.1.0: Mon Oct 20 19:32:41 PDT 2025; root:xnu-12377.41.6~2/RELEASE_ARM64_T6000
  release: 25.1.0
  cpuCount: 10
  cpuModel: Apple M1 Max
  totalMemory: 64.00 GB
  freeMemory: 0.83 GB

📦 Node.js:
  version: v20.18.1
  env: development

📦 Package Manager:
  name: pnpm
  version: 10.2.1

🔐 Better Auth:
  Version: ^1.4.4
  Configuration:
    appName: <appname>
    advanced:
      cookiePrefix: <cookieprefix>
      useSecureCookies: true
    basePath: /auth
    hooks:
      {}
    logger:
      level: info
    plugins: name: custom-session
config:
  id: custom-session
  hooks:
    after: {}
name: email-otp
config:
  id: email-otp
  endpoints:
    {}
  hooks:
    after: {}
  $ERROR_CODES:
    OTP_EXPIRED: OTP expired
    INVALID_OTP: Invalid OTP
    TOO_MANY_ATTEMPTS: Too many attempts
  rateLimit: window: 60
max: 3
window: 60
max: 3
window: 60
max: 3
window: 60
max: 3
name: signInSocial
config:
  id: signInSocial
  endpoints:
    {}
name: jwt
config:
  id: jwt
  options:
    jwt:
      issuer: <iss>
      audience: <aud>
      expirationTime: 86400s
    adapter:
      {}
    schema:
      jwks:
        modelName: JWKS
  endpoints:
    {}
  hooks:
    after: {}
  schema:
    jwks:
      fields:
        publicKey:
          type: string
          required: true
        privateKey:
          type: string
          required: true
        createdAt:
          type: date
          required: true
        expiresAt:
          type: date
          required: false
      modelName: JWKS
name: expo
config:
  id: expo
  hooks:
    after: {}
  endpoints:
    {}
name: open-api
config:
  id: open-api
  endpoints:
    {}
    rateLimit:
      enabled: true
      max: 100
      window: 300
    telemetry:
      enabled: false
    trustedOrigins: https://appleid.apple.com
    user:
      modelName: AccountUser
      additionalFields:
        providerId:
          type: string
          input: false
          required: false
          returned: false
    account:
      modelName: AccountCredential
      accountLinking:
        updateUserInfoOnLink: true
      encryptOAuthTokens: true
    session:
      modelName: AccountSession
      additionalFields:
        providerId:
          type: string
          input: false
          required: false
          returned: true
    verification:
      modelName: AccountVerification
    socialProviders:
      {}

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

Backend

Auth config (if applicable)

import { betterAuth } from 'better-auth';

export const auth = betterAuth({
  appName: '<appname>',
  advanced: {
    cookiePrefix: '<cookieprefix>',
    useSecureCookies: process.env.NODE_ENV !== 'development',
  },
  baseURL: config.baseURL,
  basePath: '/auth',
  logger: config.logger,
  // plugins,
  rateLimit: {
    // enabled: process.env.NODE_ENV !== 'development',
    enabled: true,
    max: 100,
    window: 300,
  },
  // secondaryStorage: configureSecondaryStorage(config),
  secret: config.secret,
  telemetry: { enabled: false },
  trustedOrigins: [
    ...config.trustedOrigins,
    // Required for Sign In with Apple flows
    'https://appleid.apple.com',
  ],

  // Core schema
  user: {
    modelName: 'AccountUser',
    additionalFields: {
      providerId: {
        type: 'string',
        input: false,
        required: false,
        returned: false,
      },
    },
  },
  account: {
    modelName: 'AccountCredential',
    accountLinking: {
      updateUserInfoOnLink: true,
    },
    encryptOAuthTokens: true,
  },
  session: {
    modelName: 'AccountSession',
    ...config.session,
    additionalFields: {
      providerId: {
        type: 'string',
        input: false,
        required: false,
        returned: true,
      },
    },
  },
  verification: {
    modelName: 'AccountVerification',
  },

  // Core authentication mechanisms
  // socialProviders: configureSocialProviders(config),
});

Additional context

No response

Originally created by @danielfinke on GitHub (Dec 13, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6742 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Enable `options.rateLimit` 2. Use `/sign-in/email-otp` ### Current vs. Expected behavior #### Current behavior The route-specific max of 3 reqs per 60s is ignored #### Expected behavior The client gets rate limited on the 4th req within 60s. If you log the context `path` [here](https://github.com/better-auth/better-auth/blob/8762ed8e392ef3fa78d46e34f0ed10f74b315de6/packages/better-auth/src/plugins/email-otp/index.ts#L1244), the path always has a trailing slash. Not sure when that started happening. But in plugin `after` hooks for example, the `ctx.path` doesn't have the trailing slash and so is fine by that point. I wasn't sure whether the appropriate change here was to update the hardcoded `pathMatcher` paths accordingly, or if the path should be normalized ahead of time if there could be other implications of the path having the trailing slash. Let me know what the right change is and I'd be happy to submit a PR. ### What version of Better Auth are you using? 1.4.4 ### System info ```bash Better Auth System Information ================================================== 🖥️ System Information: platform: darwin arch: arm64 version: Darwin Kernel Version 25.1.0: Mon Oct 20 19:32:41 PDT 2025; root:xnu-12377.41.6~2/RELEASE_ARM64_T6000 release: 25.1.0 cpuCount: 10 cpuModel: Apple M1 Max totalMemory: 64.00 GB freeMemory: 0.83 GB 📦 Node.js: version: v20.18.1 env: development 📦 Package Manager: name: pnpm version: 10.2.1 🔐 Better Auth: Version: ^1.4.4 Configuration: appName: <appname> advanced: cookiePrefix: <cookieprefix> useSecureCookies: true basePath: /auth hooks: {} logger: level: info plugins: name: custom-session config: id: custom-session hooks: after: {} name: email-otp config: id: email-otp endpoints: {} hooks: after: {} $ERROR_CODES: OTP_EXPIRED: OTP expired INVALID_OTP: Invalid OTP TOO_MANY_ATTEMPTS: Too many attempts rateLimit: window: 60 max: 3 window: 60 max: 3 window: 60 max: 3 window: 60 max: 3 name: signInSocial config: id: signInSocial endpoints: {} name: jwt config: id: jwt options: jwt: issuer: <iss> audience: <aud> expirationTime: 86400s adapter: {} schema: jwks: modelName: JWKS endpoints: {} hooks: after: {} schema: jwks: fields: publicKey: type: string required: true privateKey: type: string required: true createdAt: type: date required: true expiresAt: type: date required: false modelName: JWKS name: expo config: id: expo hooks: after: {} endpoints: {} name: open-api config: id: open-api endpoints: {} rateLimit: enabled: true max: 100 window: 300 telemetry: enabled: false trustedOrigins: https://appleid.apple.com user: modelName: AccountUser additionalFields: providerId: type: string input: false required: false returned: false account: modelName: AccountCredential accountLinking: updateUserInfoOnLink: true encryptOAuthTokens: true session: modelName: AccountSession additionalFields: providerId: type: string input: false required: false returned: true verification: modelName: AccountVerification socialProviders: {} ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth'; export const auth = betterAuth({ appName: '<appname>', advanced: { cookiePrefix: '<cookieprefix>', useSecureCookies: process.env.NODE_ENV !== 'development', }, baseURL: config.baseURL, basePath: '/auth', logger: config.logger, // plugins, rateLimit: { // enabled: process.env.NODE_ENV !== 'development', enabled: true, max: 100, window: 300, }, // secondaryStorage: configureSecondaryStorage(config), secret: config.secret, telemetry: { enabled: false }, trustedOrigins: [ ...config.trustedOrigins, // Required for Sign In with Apple flows 'https://appleid.apple.com', ], // Core schema user: { modelName: 'AccountUser', additionalFields: { providerId: { type: 'string', input: false, required: false, returned: false, }, }, }, account: { modelName: 'AccountCredential', accountLinking: { updateUserInfoOnLink: true, }, encryptOAuthTokens: true, }, session: { modelName: 'AccountSession', ...config.session, additionalFields: { providerId: { type: 'string', input: false, required: false, returned: true, }, }, }, verification: { modelName: 'AccountVerification', }, // Core authentication mechanisms // socialProviders: configureSocialProviders(config), }); ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-17 19:13:21 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Dec 13, 2025):

The root cause is that the rate limiter receives the raw request path (which may have a trailing slash), but the pathMatcher functions in the email-otp plugin expect exact matches without a trailing slash. This mismatch means the route-specific rate limit is skipped if the request path ends with a slash, so clients aren't limited as expected see code.

The most robust fix is to normalize the path before matching—specifically, remove any trailing slash from the path string before passing it to the pathMatcher functions in the rate limiting logic. This ensures consistent matching regardless of how the client formats the URL and avoids having to update every matcher or risk subtle bugs elsewhere see rate limiter logic.

A simple normalization function could look like this:

function normalizePath(path: string) {
  return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
}

Apply this normalization to the path variable right after removing the basePath, before any matcher checks. For example, in the rate limiter:

let path = new URL(req.url).pathname.replace(ctx.options.basePath || "/api/auth", "");
path = normalizePath(path);

This will make all path matchers work as intended, including for /sign-in/email-otp, and avoids surprises for other plugins or routes. If you want to submit a PR, this is the recommended approach.

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:3649926885 --> @dosubot[bot] commented on GitHub (Dec 13, 2025): <!-- Answer --> The root cause is that the rate limiter receives the raw request path (which may have a trailing slash), but the pathMatcher functions in the email-otp plugin expect exact matches without a trailing slash. This mismatch means the route-specific rate limit is skipped if the request path ends with a slash, so clients aren't limited as expected [see code](https://github.com/better-auth/better-auth/blob/99defeaf80b200f71be29c813d48afa985b86157/packages/better-auth/src/plugins/email-otp/index.ts#L111-L138). The most robust fix is to normalize the path before matching—specifically, remove any trailing slash from the path string before passing it to the pathMatcher functions in the rate limiting logic. This ensures consistent matching regardless of how the client formats the URL and avoids having to update every matcher or risk subtle bugs elsewhere [see rate limiter logic](https://github.com/better-auth/better-auth/blob/99defeaf80b200f71be29c813d48afa985b86157/packages/better-auth/src/api/rate-limiter/index.ts#L138-L251). A simple normalization function could look like this: ```ts function normalizePath(path: string) { return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; } ``` Apply this normalization to the path variable right after removing the basePath, before any matcher checks. For example, in the rate limiter: ```ts let path = new URL(req.url).pathname.replace(ctx.options.basePath || "/api/auth", ""); path = normalizePath(path); ``` This will make all path matchers work as intended, including for `/sign-in/email-otp`, and avoids surprises for other plugins or routes. If you want to submit a PR, this is the recommended approach. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/c62eeef1-428f-4c72-8f6c-04b4372dd252?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/c62eeef1-428f-4c72-8f6c-04b4372dd252?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/c62eeef1-428f-4c72-8f6c-04b4372dd252?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/c62eeef1-428f-4c72-8f6c-04b4372dd252?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/c62eeef1-428f-4c72-8f6c-04b4372dd252?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/c62eeef1-428f-4c72-8f6c-04b4372dd252?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/c62eeef1-428f-4c72-8f6c-04b4372dd252?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)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20root%20cause%20is%20that%20the%20rate%20limiter%20receives%20the%20raw%20request%20path%20%28which%20may%20have%20a%20trailing%20slash%29%2C%20but%20the%20pathMatcher%20functions%20in%20the%20email-otp%20plugin%20expect%20exact%20matches%20without%20a%20trailing%20slash.%20This%20mismatch%20means%20the%20route-specific%20rate%20limit%20is%20skipped%20if%20the%20request%20path%20ends%20with%20a%20slash%2C%20so%20clients%20aren%27t%20limited%20as%20expected%C2%A0%5Bsee%20code%5D%28https%3A//github.com/better-auth/better-auth/blob/99defeaf80b200f71be29c813d48afa985b86157/packages/better-auth/src/plugins/email-otp/index.ts%23L111-L138%29.%0A%0AThe%20most%20robust%20fix%20is%20to%20normalize%20the%20path%20before%20matching%E2%80%94specifically%2C%20remove%20any%20trailing%20slash%20from%20the%20path%20string%20before%20passing%20it%20to%20the%20pathMatcher%20functions%20in%20the%20rate%20limiting%20logic.%20This%20ensures%20consistent%20matching%20regardless%20of%20how%20the%20client%20formats%20the%20URL%20and%20avoids%20having%20to%20update%20every%20matcher%20or%20risk%20subtle%20bugs%20elsewhere%C2%A0%5Bsee%20rate%20limiter%20logic%5D%28https%3A//github.com/better-auth/better-auth/blob/99defeaf80b200f71be29c813d48afa985b86157/packages/better-auth/src/api/rate-limiter/index.ts%23L138-L251%29.%0A%0AA%20simple%20normalization%20function%20could%20look%20like%20this%3A%0A%0A%60%60%60ts%0Afunction%20normalizePath%28path%3A%20string%29%20%7B%0A%20%20return%20path.length%20%3E%201%20%26%26%20path.endsWith%28%27/%27%29%20%3F%20path.slice%280%2C%20-1%29%20%3A%20path%3B%0A%7D%0A%60%60%60%0A%0AApply%20this%20normalization%20to%20the%20path%20variable%20right%20after%20removing%20the%20basePath%2C%20before%20any%20matcher%20checks.%20For%20example%2C%20in%20the%20rate%20limiter%3A%0A%0A%60%60%60ts%0Alet%20path%20%3D%20new%20URL%28req.url%29.pathname.replace%28ctx.options.basePath%20%7C%7C%20%22/api/auth%22%2C%20%22%22%29%3B%0Apath%20%3D%20normalizePath%28path%29%3B%0A%60%60%60%0A%0AThis%20will%20make%20all%20path%20matchers%20work%20as%20intended%2C%20including%20for%20%60/sign-in/email-otp%60%2C%20and%20avoids%20surprises%20for%20other%20plugins%20or%20routes.%20If%20you%20want%20to%20submit%20a%20PR%2C%20this%20is%20the%20recommended%20approach.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&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/6742)
Author
Owner

@jslno commented on GitHub (Dec 14, 2025):

Try upgrading to latest. #6538 should've solved this

<!-- gh-comment-id:3650013825 --> @jslno commented on GitHub (Dec 14, 2025): Try upgrading to latest. #6538 should've solved this
Author
Owner

@danielfinke commented on GitHub (Dec 14, 2025):

Try upgrading to latest. #6538 should've solved this

Thanks, yep, it looks like it's fixed in the 1.4.7 betas. I had checked 1.4.6, but not newer.

<!-- gh-comment-id:3650039310 --> @danielfinke commented on GitHub (Dec 14, 2025): > Try upgrading to latest. [#6538](https://github.com/better-auth/better-auth/pull/6538) should've solved this Thanks, yep, it looks like it's fixed in the 1.4.7 betas. I had checked 1.4.6, but not newer.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#27933