From 37a75c95d148e0db4f29f30cabcd2ceb26bc7085 Mon Sep 17 00:00:00 2001 From: Aryan <60398102+aryan1306@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:02:04 +0000 Subject: [PATCH] fix(rate-limit): convert a string lastRequest before computing X-Retry-After (#11521) Co-authored-by: Taesu --- .changeset/rate-limit-string-last-request.md | 5 ++ .../better-auth/src/api/rate-limiter/index.ts | 2 +- .../src/api/rate-limiter/rate-limiter.test.ts | 57 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 .changeset/rate-limit-string-last-request.md diff --git a/.changeset/rate-limit-string-last-request.md b/.changeset/rate-limit-string-last-request.md new file mode 100644 index 0000000000..d9fd23af03 --- /dev/null +++ b/.changeset/rate-limit-string-last-request.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Fix database-backed rate limiting returning an excessively large `X-Retry-After` header instead of the remaining seconds in the rate-limit window. diff --git a/packages/better-auth/src/api/rate-limiter/index.ts b/packages/better-auth/src/api/rate-limiter/index.ts index 3defadbd59..07e85c19c2 100644 --- a/packages/better-auth/src/api/rate-limiter/index.ts +++ b/packages/better-auth/src/api/rate-limiter/index.ts @@ -125,7 +125,7 @@ function createDatabaseStorageWrapper( where: [{ field: "key", value: key }], }); const data = res[0]; - if (typeof data?.lastRequest === "bigint") { + if (data) { data.lastRequest = Number(data.lastRequest); } return data; diff --git a/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts b/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts index b2b2851b6b..9d3aaa7d27 100644 --- a/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts +++ b/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts @@ -439,6 +439,63 @@ describe("database rate-limit cleanup scheduling", async () => { }); }); +/** + * @see https://github.com/better-auth/better-auth/issues/11519 + */ +describe("database rate-limit with a string lastRequest", async () => { + const { auth, client, testUser } = await getTestInstance({ + rateLimit: { + enabled: true, + storage: "database", + customRules: { + "/sign-in/email": { window: 60, max: 1 }, + }, + }, + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should respond with the remaining seconds in the retry-after header", async () => { + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + // node-postgres returns `int8` columns as strings by default. + const { adapter } = await auth.$context; + const findMany = adapter.findMany.bind(adapter); + vi.spyOn(adapter, "findMany").mockImplementation(async (args) => { + const rows = await findMany(args); + if (args.model !== "rateLimit") return rows; + return rows.map((row) => ({ + ...(row as RateLimit), + lastRequest: String((row as RateLimit).lastRequest), + })) as typeof rows; + }); + + const first = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + expect(first.error).toBeNull(); + + clock.mockReturnValue(now + 1_001); + let retryAfter = ""; + const blocked = await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onError(context) { + retryAfter = context.response.headers.get("X-Retry-After") ?? ""; + }, + }, + ); + expect(blocked.error?.status).toBe(429); + expect(retryAfter).toBe("59"); + }); +}); + describe("custom rate limiting storage", async () => { const store = new Map(); const expirationMap = new Map();