feat(api-key): add option to disable inline expired key cleanup

This commit is contained in:
Gautam Manchandani
2026-04-13 15:11:23 +05:30
parent 6c946a3e0c
commit 62ffe9dcd7
11 changed files with 383 additions and 28 deletions
@@ -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.
</Callout>
## Next Steps
@@ -86,7 +86,7 @@ The minimum length of the name.
Whether to enable metadata for an API key.
`keyExpiration` <span className="opacity-70">`{ defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number; }`</span>
`keyExpiration` <span className="opacity-70">`{ defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number; autoCleanup?: boolean; }`</span>
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` <span className="opacity-70">`boolean`</span>
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`.
</Accordion>
</Accordions>
+308
View File
@@ -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<Promise<unknown>> = [];
const { auth, signInWithTestUser } = await getTestInstance({
advanced: {
backgroundTasks: {
handler: (promise: Promise<unknown>) => {
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);
});
});
});
+24 -11
View File
@@ -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);
@@ -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,
@@ -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,
});
+3 -1
View File
@@ -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(
+11 -8
View File
@@ -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) => {
@@ -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(
@@ -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);
+9
View File
@@ -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;
/**