From 62ffe9dcd7e9975da7d5f616e8c8e364cb02185b Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Mon, 13 Apr 2026 15:01:03 +0530 Subject: [PATCH] feat(api-key): add option to disable inline expired key cleanup --- docs/content/docs/plugins/api-key/index.mdx | 4 + .../docs/plugins/api-key/reference.mdx | 9 +- packages/api-key/src/api-key.test.ts | 308 ++++++++++++++++++ packages/api-key/src/index.ts | 35 +- packages/api-key/src/routes/create-api-key.ts | 4 +- packages/api-key/src/routes/delete-api-key.ts | 4 +- packages/api-key/src/routes/get-api-key.ts | 4 +- packages/api-key/src/routes/list-api-keys.ts | 19 +- packages/api-key/src/routes/update-api-key.ts | 4 +- packages/api-key/src/routes/verify-api-key.ts | 11 +- packages/api-key/src/types.ts | 9 + 11 files changed, 383 insertions(+), 28 deletions(-) diff --git a/docs/content/docs/plugins/api-key/index.mdx b/docs/content/docs/plugins/api-key/index.mdx index 4cdfcbd649..be0b35e1fb 100644 --- a/docs/content/docs/plugins/api-key/index.mdx +++ b/docs/content/docs/plugins/api-key/index.mdx @@ -454,6 +454,10 @@ This function will delete all API keys that have an expired expiration date. We automatically delete expired API keys every time any apiKey plugin endpoints were called, however they are rate-limited to a 10 second cool down each call to prevent multiple calls to the database. + + If you'd rather manage cleanup yourself, set `keyExpiration.autoCleanup` to + `false` and call `auth.api.deleteAllExpiredApiKeys()` from a cron job or other + external maintenance flow. ## Next Steps diff --git a/docs/content/docs/plugins/api-key/reference.mdx b/docs/content/docs/plugins/api-key/reference.mdx index 2cf6b8454b..3f539d1333 100644 --- a/docs/content/docs/plugins/api-key/reference.mdx +++ b/docs/content/docs/plugins/api-key/reference.mdx @@ -86,7 +86,7 @@ The minimum length of the name. Whether to enable metadata for an API key. -`keyExpiration` `{ defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number; }` +`keyExpiration` `{ defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number; autoCleanup?: boolean; }` Customize the key expiration. @@ -113,6 +113,13 @@ Customize the key expiration. The maximum expiresIn value allowed to be set from the client. in days. Default is `365`. + + `autoCleanup` `boolean` + + Whether to automatically clean up expired API keys inline during API key requests. + If `false`, inline cleanup is disabled and you can call `auth.api.deleteAllExpiredApiKeys()` + from a cron job or another external maintenance task instead. + Default is `true`. diff --git a/packages/api-key/src/api-key.test.ts b/packages/api-key/src/api-key.test.ts index 2f93f3a276..0d25db3730 100644 --- a/packages/api-key/src/api-key.test.ts +++ b/packages/api-key/src/api-key.test.ts @@ -32,6 +32,15 @@ describe("api-key", async () => { vi.useRealTimers(); }); + const setCleanupCheckTime = (date = new Date("2030-01-01T00:00:00.000Z")) => { + vi.useFakeTimers(); + vi.setSystemTime(date); + }; + + const advanceCleanupCheckTime = () => { + vi.advanceTimersByTime(11_000); + }; + // ========================================================================= // CREATE API KEY // ========================================================================= @@ -4435,4 +4444,303 @@ describe("api-key", async () => { expect(updatedKey.referenceId).toBe(org.id); }); }); + + describe("keyExpiration.autoCleanup", () => { + it("should keep inline cleanup enabled by default", async () => { + setCleanupCheckTime(); + + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [apiKey()], + }); + + const { user } = await signInWithTestUser(); + const ctx = await auth.$context; + const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany"); + + await auth.api.createApiKey({ + body: { + userId: user.id, + }, + }); + + expect(deleteManySpy).toHaveBeenCalledWith( + expect.objectContaining({ + model: "apikey", + }), + ); + }); + + it("should disable inline cleanup for create, get, list, update, and delete when autoCleanup is false", async () => { + setCleanupCheckTime(); + + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [ + apiKey({ + keyExpiration: { + autoCleanup: false, + }, + }), + ], + }); + + const { headers, user } = await signInWithTestUser(); + const ctx = await auth.$context; + const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany"); + + const createdKey = await auth.api.createApiKey({ + body: { + userId: user.id, + }, + }); + + expect(deleteManySpy).not.toHaveBeenCalled(); + + advanceCleanupCheckTime(); + await auth.api.getApiKey({ + query: { + id: createdKey.id, + }, + headers, + }); + expect(deleteManySpy).not.toHaveBeenCalled(); + + advanceCleanupCheckTime(); + await auth.api.listApiKeys({ + headers, + }); + expect(deleteManySpy).not.toHaveBeenCalled(); + + advanceCleanupCheckTime(); + await auth.api.updateApiKey({ + body: { + keyId: createdKey.id, + name: "updated-inline-cleanup-disabled", + }, + headers, + }); + expect(deleteManySpy).not.toHaveBeenCalled(); + + advanceCleanupCheckTime(); + await auth.api.deleteApiKey({ + body: { + keyId: createdKey.id, + }, + headers, + }); + expect(deleteManySpy).not.toHaveBeenCalled(); + }); + + it("should not run inline cleanup from the API key session hook when autoCleanup is false", async () => { + setCleanupCheckTime(); + + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [ + apiKey({ + enableSessionForAPIKeys: true, + keyExpiration: { + autoCleanup: false, + }, + }), + ], + }); + + const { user } = await signInWithTestUser(); + const ctx = await auth.$context; + const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany"); + + const createdKey = await auth.api.createApiKey({ + body: { + userId: user.id, + }, + }); + + deleteManySpy.mockClear(); + advanceCleanupCheckTime(); + + const apiKeyHeaders = new Headers({ + "x-api-key": createdKey.key, + }); + + const session = await auth.api.getSession({ + headers: apiKeyHeaders, + }); + + expect(session?.session).toBeDefined(); + expect(deleteManySpy).not.toHaveBeenCalled(); + }); + + it("should not queue expired key cleanup during verify when autoCleanup is false and deferUpdates is enabled", async () => { + setCleanupCheckTime(); + + const deferredPromises: Array> = []; + const { auth, signInWithTestUser } = await getTestInstance({ + advanced: { + backgroundTasks: { + handler: (promise: Promise) => { + deferredPromises.push(promise); + }, + }, + }, + plugins: [ + apiKey({ + deferUpdates: true, + keyExpiration: { + autoCleanup: false, + }, + }), + ], + }); + + const { user } = await signInWithTestUser(); + const ctx = await auth.$context; + const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany"); + + const createdKey = await auth.api.createApiKey({ + body: { + userId: user.id, + }, + }); + + deleteManySpy.mockClear(); + deferredPromises.length = 0; + + const result = await auth.api.verifyApiKey({ + body: { + key: createdKey.key, + }, + }); + + expect(result.valid).toBe(true); + expect(deferredPromises.length).toBeGreaterThan(0); + + await Promise.all(deferredPromises); + + expect(deleteManySpy).not.toHaveBeenCalled(); + }); + + it("should still delete expired API keys through the explicit cleanup endpoint when autoCleanup is false", async () => { + setCleanupCheckTime(); + + const { auth, signInWithTestUser, db } = await getTestInstance({ + plugins: [ + apiKey({ + keyExpiration: { + autoCleanup: false, + }, + }), + ], + }); + + const { user } = await signInWithTestUser(); + + const createdKey = await auth.api.createApiKey({ + body: { + userId: user.id, + }, + }); + + await db.update({ + model: "apikey", + where: [ + { + field: "id", + value: createdKey.id, + }, + ], + update: { + expiresAt: new Date(Date.now() - 60_000), + }, + }); + + const expiredKeyBeforeCleanup = await db.findOne({ + model: "apikey", + where: [ + { + field: "id", + value: createdKey.id, + }, + ], + }); + + expect(expiredKeyBeforeCleanup).not.toBeNull(); + + const result = await auth.api.deleteAllExpiredApiKeys(); + + expect(result.success).toBe(true); + + const expiredKeyAfterCleanup = await db.findOne({ + model: "apikey", + where: [ + { + field: "id", + value: createdKey.id, + }, + ], + }); + + expect(expiredKeyAfterCleanup).toBeNull(); + }); + + it("should use the resolved key configuration to decide inline cleanup in multi-config routes", async () => { + setCleanupCheckTime(); + + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [ + apiKey([ + { + configId: "default", + keyExpiration: { + autoCleanup: true, + }, + }, + { + configId: "manual-cleanup", + keyExpiration: { + autoCleanup: false, + }, + }, + ]), + ], + }); + + const { headers, user } = await signInWithTestUser(); + const ctx = await auth.$context; + const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany"); + + const defaultKey = await auth.api.createApiKey({ + body: { + userId: user.id, + configId: "default", + }, + }); + const manualCleanupKey = await auth.api.createApiKey({ + body: { + userId: user.id, + configId: "manual-cleanup", + }, + }); + + deleteManySpy.mockClear(); + + advanceCleanupCheckTime(); + await auth.api.getApiKey({ + query: { + id: manualCleanupKey.id, + configId: "manual-cleanup", + }, + headers, + }); + expect(deleteManySpy).not.toHaveBeenCalled(); + + advanceCleanupCheckTime(); + await auth.api.getApiKey({ + query: { + id: defaultKey.id, + configId: "default", + }, + headers, + }); + expect(deleteManySpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/api-key/src/index.ts b/packages/api-key/src/index.ts index 13b77f866d..99e65d050c 100644 --- a/packages/api-key/src/index.ts +++ b/packages/api-key/src/index.ts @@ -8,7 +8,11 @@ import { generateRandomString } from "better-auth/crypto"; import { mergeSchema } from "better-auth/db"; import { API_KEY_ERROR_CODES } from "./error-codes"; import type { PredefinedApiKeyOptions } from "./routes"; -import { createApiKeyRoutes, deleteAllExpiredApiKeys } from "./routes"; +import { + createApiKeyRoutes, + deleteAllExpiredApiKeys, + resolveConfiguration, +} from "./routes"; import { validateApiKey } from "./routes/verify-api-key"; import { apiKeySchema } from "./schema"; import type { ApiKeyConfigurationOptions, ApiKeyOptions } from "./types"; @@ -94,6 +98,7 @@ export function apiKey( config?.keyExpiration?.disableCustomExpiresTime ?? false, maxExpiresIn: config?.keyExpiration?.maxExpiresIn ?? 365, minExpiresIn: config?.keyExpiration?.minExpiresIn ?? 1, + autoCleanup: config?.keyExpiration?.autoCleanup ?? true, }, startingCharactersConfig: { shouldStore: config?.startingCharactersConfig?.shouldStore ?? true, @@ -211,21 +216,29 @@ export function apiKey( schema, }); - const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch( - (err) => { - ctx.context.logger.error( - "Failed to delete expired API keys:", - err, - ); - }, + const resolvedConfig = resolveConfiguration( + ctx.context, + configurations, + apiKey.configId, ); - if (config.deferUpdates) { - ctx.context.runInBackground(cleanupTask); + + if (resolvedConfig.keyExpiration.autoCleanup) { + const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch( + (err) => { + ctx.context.logger.error( + "Failed to delete expired API keys:", + err, + ); + }, + ); + if (resolvedConfig.deferUpdates) { + ctx.context.runInBackground(cleanupTask); + } } // Session mocking only works for user-owned API keys // Determine the reference type from the configuration - const referencesType = config.references ?? "user"; + const referencesType = resolvedConfig.references ?? "user"; if (referencesType !== "user") { const msg = API_KEY_ERROR_CODES.INVALID_REFERENCE_ID_FROM_API_KEY; throw APIError.from("UNAUTHORIZED", msg); diff --git a/packages/api-key/src/routes/create-api-key.ts b/packages/api-key/src/routes/create-api-key.ts index 1f4a45d73a..4999e5b4ae 100644 --- a/packages/api-key/src/routes/create-api-key.ts +++ b/packages/api-key/src/routes/create-api-key.ts @@ -419,7 +419,9 @@ export function createApiKey({ throw APIError.from("BAD_REQUEST", ERROR_CODES.NAME_REQUIRED); } - deleteAllExpiredApiKeys(ctx.context); + if (opts.keyExpiration.autoCleanup) { + deleteAllExpiredApiKeys(ctx.context); + } const key = await keyGenerator({ length: opts.defaultKeyLength, diff --git a/packages/api-key/src/routes/delete-api-key.ts b/packages/api-key/src/routes/delete-api-key.ts index 214d035ab7..2800ecc373 100644 --- a/packages/api-key/src/routes/delete-api-key.ts +++ b/packages/api-key/src/routes/delete-api-key.ts @@ -163,7 +163,9 @@ export function deleteApiKey({ message: error?.message, }); } - deleteAllExpiredApiKeys(ctx.context); + if (opts.keyExpiration.autoCleanup) { + deleteAllExpiredApiKeys(ctx.context); + } return ctx.json({ success: true, }); diff --git a/packages/api-key/src/routes/get-api-key.ts b/packages/api-key/src/routes/get-api-key.ts index 8ce9c1be20..27be35e0a5 100644 --- a/packages/api-key/src/routes/get-api-key.ts +++ b/packages/api-key/src/routes/get-api-key.ts @@ -225,7 +225,9 @@ export function getApiKey({ throw APIError.from("NOT_FOUND", ERROR_CODES.KEY_NOT_FOUND); } - deleteAllExpiredApiKeys(ctx.context); + if (opts.keyExpiration.autoCleanup) { + deleteAllExpiredApiKeys(ctx.context); + } // Migrate legacy double-stringified metadata if needed const metadata = await migrateDoubleStringifiedMetadata( diff --git a/packages/api-key/src/routes/list-api-keys.ts b/packages/api-key/src/routes/list-api-keys.ts index 9bd7dbfb2f..c90433d16f 100644 --- a/packages/api-key/src/routes/list-api-keys.ts +++ b/packages/api-key/src/routes/list-api-keys.ts @@ -279,14 +279,11 @@ export function listApiKeys({ const expectedReferencesType = organizationId ? "organization" : "user"; let allApiKeys: ApiKey[] = []; + const resolvedConfig = configId + ? resolveConfiguration(ctx.context, configurations, configId) + : null; - if (configId) { - const resolvedConfig = resolveConfiguration( - ctx.context, - configurations, - configId, - ); - + if (resolvedConfig) { const { apiKeys } = await listApiKeysFromStorage( ctx, referenceId, @@ -366,7 +363,13 @@ export function listApiKeys({ paginatedApiKeys = paginatedApiKeys.slice(0, limit); } - deleteAllExpiredApiKeys(ctx.context); + const shouldAutoCleanup = resolvedConfig + ? resolvedConfig.keyExpiration.autoCleanup + : configurations.every((config) => config.keyExpiration.autoCleanup); + + if (shouldAutoCleanup) { + deleteAllExpiredApiKeys(ctx.context); + } // Build response with parsed metadata (synchronous, no DB calls) const returningApiKeys = paginatedApiKeys.map((apiKey) => { diff --git a/packages/api-key/src/routes/update-api-key.ts b/packages/api-key/src/routes/update-api-key.ts index 1caae4d8f5..a87c8c02e4 100644 --- a/packages/api-key/src/routes/update-api-key.ts +++ b/packages/api-key/src/routes/update-api-key.ts @@ -475,7 +475,9 @@ export function updateApiKey({ }); } - deleteAllExpiredApiKeys(ctx.context); + if (opts.keyExpiration.autoCleanup) { + deleteAllExpiredApiKeys(ctx.context); + } // Migrate legacy double-stringified metadata if needed const migratedMetadata = await migrateDoubleStringifiedMetadata( diff --git a/packages/api-key/src/routes/verify-api-key.ts b/packages/api-key/src/routes/verify-api-key.ts index 2e44febaca..402aec307f 100644 --- a/packages/api-key/src/routes/verify-api-key.ts +++ b/packages/api-key/src/routes/verify-api-key.ts @@ -298,15 +298,18 @@ export function verifyApiKey({ ? resolveConfiguration(ctx.context, configurations, apiKey.configId) : lookupOpts; - if (opts.deferUpdates) { - ctx.context.runInBackground( - deleteAllExpiredApiKeys(ctx.context).catch((err) => { + if (opts.keyExpiration.autoCleanup) { + const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch( + (err) => { ctx.context.logger.error( "Failed to delete expired API keys:", err, ); - }), + }, ); + if (opts.deferUpdates) { + ctx.context.runInBackground(cleanupTask); + } } } catch (error) { ctx.context.logger.error("Failed to validate API key:", error); diff --git a/packages/api-key/src/types.ts b/packages/api-key/src/types.ts index 8b2493e54a..9ced462232 100644 --- a/packages/api-key/src/types.ts +++ b/packages/api-key/src/types.ts @@ -162,6 +162,15 @@ export interface ApiKeyConfigurationOptions { * @default 365 */ maxExpiresIn?: number; + /** + * Whether to automatically clean up expired API keys inline during API key requests. + * + * If `false`, expired key cleanup must be handled manually, for example by calling + * `auth.api.deleteAllExpiredApiKeys()` from a cron job. + * + * @default true + */ + autoCleanup?: boolean; } | undefined; /**