Rate limiter keys in secondary storage lack TTL, causing memory growth over time #1861

Closed
opened 2026-03-13 09:09:02 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @ianseddon on GitHub (Sep 5, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Configure better-auth with any secondary storage (e.g., Redis, DynamoDB, etc.)
  2. Set rate limiting to use secondary storage:
    rateLimit: {
      window: 10, // seconds
      max: 100,
      storage: "secondary-storage"
    }
    
  3. Make requests from different IPs to trigger rate limit key creation.
  4. Observe that keys are created in secondary storage but never expire (without additional configuration of eviction policies on the secondary storage)
  5. Over time, storage fills with stale rate limit keys

Current vs. Expected behavior

Current: Rate limit keys are stored indefinitely in secondary storage.
Expected: Stale rate limiter state should automatically expire after the rate limit window has passed.

What version of Better Auth are you using?

1.3.7

System info

{
  "system": {
    "platform": "linux",
    "arch": "x64",
    "version": "#1 SMP PREEMPT_DYNAMIC Sun, 06 Jul 2025 11:14:36 +0000",
    "release": "6.15.5-arch1-1",
    "cpuCount": 32,
    "cpuModel": "13th Gen Intel(R) Core(TM) i9-13900K",
    "totalMemory": "62.56 GB",
    "freeMemory": "46.68 GB"
  },
  "node": {
    "version": "v22.14.0",
    "env": "development"
  },
  "packageManager": {
    "name": "bun",
    "version": "1.2.17"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "^15.3.5"
    },
    {
      "name": "react",
      "version": "^19.0.0"
    }
  ],
  "databases": [
    {
      "name": "@prisma/client",
      "version": "^6.14.0"
    }
  ],
  "betterAuth": {
    "version": "0.1.0",
    "config": {
      "baseURL": "http://localhost:3000",
      "secondaryStorage": {},
      "emailAndPassword": {
        "enabled": false
      },
      "plugins": [
        {
          "name": "next-cookies",
          "config": {
            "id": "next-cookies",
            "hooks": {
              "after": [
                {}
              ]
            }
          }
        },
        {
          "name": "generic-oauth",
          "config": {
            "id": "generic-oauth",
            "endpoints": {},
            "$ERROR_CODES": {
              "INVALID_OAUTH_CONFIGURATION": "Invalid OAuth configuration"
            }
          }
        }
      ],
      "rateLimit": {
        "enabled": true,
        "storage": "secondary-storage",
        "window": 60,
        "max": 100
      },
      "logger": {
        "disabled": false,
        "level": "debug"
      }
    }
  }
}

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  // ...other config
  secondaryStorage: {
    get: async (key: string) => {
      return await client.get(key);
    },
    set: async (key: string, value: string, ttl?: number) => {
      if (ttl) {
        await client.set(key, value, "EX", ttl);
      }
      else {
        await client.set(key, value);
      }
    },
    delete: async (key: string) => {
      await client.del(key);
    },
  },
  rateLimit: {
    window: 10,
    max: 100,
    storage: "secondary-storage"
  }
});

Additional context

Proposed solution: Pass TTL = window (perhaps plus a buffer) to the storage set method, allowing implementations to handle expiration appropriately

Originally created by @ianseddon on GitHub (Sep 5, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Configure better-auth with any secondary storage (e.g., Redis, DynamoDB, etc.) 2. Set rate limiting to use secondary storage: ```typescript rateLimit: { window: 10, // seconds max: 100, storage: "secondary-storage" } ``` 3. Make requests from different IPs to trigger rate limit key creation. 4. Observe that keys are created in secondary storage but never expire (without additional configuration of eviction policies on the secondary storage) 5. Over time, storage fills with stale rate limit keys ### Current vs. Expected behavior Current: Rate limit keys are stored indefinitely in secondary storage. Expected: Stale rate limiter state should automatically expire after the rate limit window has passed. ### What version of Better Auth are you using? 1.3.7 ### System info ```bash { "system": { "platform": "linux", "arch": "x64", "version": "#1 SMP PREEMPT_DYNAMIC Sun, 06 Jul 2025 11:14:36 +0000", "release": "6.15.5-arch1-1", "cpuCount": 32, "cpuModel": "13th Gen Intel(R) Core(TM) i9-13900K", "totalMemory": "62.56 GB", "freeMemory": "46.68 GB" }, "node": { "version": "v22.14.0", "env": "development" }, "packageManager": { "name": "bun", "version": "1.2.17" }, "frameworks": [ { "name": "next", "version": "^15.3.5" }, { "name": "react", "version": "^19.0.0" } ], "databases": [ { "name": "@prisma/client", "version": "^6.14.0" } ], "betterAuth": { "version": "0.1.0", "config": { "baseURL": "http://localhost:3000", "secondaryStorage": {}, "emailAndPassword": { "enabled": false }, "plugins": [ { "name": "next-cookies", "config": { "id": "next-cookies", "hooks": { "after": [ {} ] } } }, { "name": "generic-oauth", "config": { "id": "generic-oauth", "endpoints": {}, "$ERROR_CODES": { "INVALID_OAUTH_CONFIGURATION": "Invalid OAuth configuration" } } } ], "rateLimit": { "enabled": true, "storage": "secondary-storage", "window": 60, "max": 100 }, "logger": { "disabled": false, "level": "debug" } } } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ // ...other config secondaryStorage: { get: async (key: string) => { return await client.get(key); }, set: async (key: string, value: string, ttl?: number) => { if (ttl) { await client.set(key, value, "EX", ttl); } else { await client.set(key, value); } }, delete: async (key: string) => { await client.del(key); }, }, rateLimit: { window: 10, max: 100, storage: "secondary-storage" } }); ``` ### Additional context Proposed solution: Pass TTL = window (perhaps plus a buffer) to the storage set method, allowing implementations to handle expiration appropriately
GiteaMirror added the bug label 2026-03-13 09:09:02 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Sep 5, 2025):

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

This is a known limitation in Better Auth 1.3.7: when using secondary storage for rate limiting, the rate limiter does not pass a TTL to the storage adapter, so keys persist indefinitely unless your storage system enforces its own eviction policy. The set method in your secondaryStorage adapter supports TTL, but the rate limiter code currently calls it without passing the window as TTL, so keys do not expire automatically (sourcePR).

Your proposed solution—to pass TTL equal to the rate limit window (plus a buffer) to the set method—is feasible and matches community recommendations (discussion). However, this is not implemented in the current codebase, and there is no official workaround or documentation for this yet.

If you want to fix this, you’ll need to patch the rate limiter logic to pass the window value as the TTL argument when calling secondaryStorage.set. For example:

await ctx.options.secondaryStorage?.set?.(key, JSON.stringify(value), window);

This will ensure stale rate limiter keys expire after the window period.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 5, 2025): <!-- Greeting --> Hi @ianseddon! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known limitation in Better Auth 1.3.7: when using secondary storage for rate limiting, the rate limiter does not pass a TTL to the storage adapter, so keys persist indefinitely unless your storage system enforces its own eviction policy. The set method in your secondaryStorage adapter supports TTL, but the rate limiter code currently calls it without passing the window as TTL, so keys do not expire automatically ([source](https://github.com/better-auth/better-auth/blob/a39d09d3ff83b526fb9caf691d3bbcb3b7b2335b/packages/better-auth/src/api/rate-limiter/index.ts), [PR](https://github.com/better-auth/better-auth/pull/4076)). Your proposed solution—to pass TTL equal to the rate limit window (plus a buffer) to the set method—is feasible and matches community recommendations ([discussion](https://github.com/better-auth/better-auth/issues/4034)). However, this is not implemented in the current codebase, and there is no official workaround or documentation for this yet. If you want to fix this, you’ll need to patch the rate limiter logic to pass the window value as the TTL argument when calling secondaryStorage.set. For example: ```typescript await ctx.options.secondaryStorage?.set?.(key, JSON.stringify(value), window); ``` This will ensure stale rate limiter keys expire after the window period. <!-- 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/6ddf0fc4-c85e-445e-8e42-3ff90d143fff?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/6ddf0fc4-c85e-445e-8e42-3ff90d143fff?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/6ddf0fc4-c85e-445e-8e42-3ff90d143fff?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/6ddf0fc4-c85e-445e-8e42-3ff90d143fff?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/6ddf0fc4-c85e-445e-8e42-3ff90d143fff?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/6ddf0fc4-c85e-445e-8e42-3ff90d143fff?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/6ddf0fc4-c85e-445e-8e42-3ff90d143fff?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&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/4472)
Author
Owner

@ianseddon commented on GitHub (Sep 8, 2025):

We are currently working around this by providing a customStorage implementation which sets the TTL = window + 1 :

export const auth = betterAuth({
  // ... other config
  secondaryStorage,
  rateLimit: {
    window,
    // storage: 'secondary-storage',
    customStorage: {
      get: async (key: string) => {
        const data = await secondaryStorage?.get(key)
        return data ? (safeJSONParse<RateLimit>(data) ?? undefined) : undefined;
      },
      set: async (key: string, value: RateLimit) => {
        await secondaryStorage?.set(key, JSON.stringify(value), window + 1)
       }
    }
})
@ianseddon commented on GitHub (Sep 8, 2025): We are currently working around this by providing a `customStorage` implementation which sets the TTL = `window + 1` : ```typescript export const auth = betterAuth({ // ... other config secondaryStorage, rateLimit: { window, // storage: 'secondary-storage', customStorage: { get: async (key: string) => { const data = await secondaryStorage?.get(key) return data ? (safeJSONParse<RateLimit>(data) ?? undefined) : undefined; }, set: async (key: string, value: RateLimit) => { await secondaryStorage?.set(key, JSON.stringify(value), window + 1) } } }) ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1861