diff --git a/apps/api/src/pkg/cache/index.ts b/apps/api/src/pkg/cache/index.ts
index 43fec829eb..1260505a2b 100644
--- a/apps/api/src/pkg/cache/index.ts
+++ b/apps/api/src/pkg/cache/index.ts
@@ -35,7 +35,7 @@ export function initCache(c: Context, metrics: Metrics): C;
export type CacheNamespaces = {
keyById: {
- key: Key & { encrypted: EncryptedKey | null };
+ key: Key & { encrypted: EncryptedKey | null; ratelimits: Ratelimit[] };
api: Api;
permissions: string[];
roles: string[];
@@ -30,16 +30,16 @@ export type CacheNamespaces = {
id: string;
enabled: boolean;
} | null;
- key: Key & { encrypted: EncryptedKey | null };
+ key: Key & { encrypted: EncryptedKey | null; ratelimits: Ratelimit[] };
api: Api;
permissions: string[];
roles: string[];
- ratelimits: { [name: string]: Pick };
+ ratelimits: { [name: string]: Pick };
identity: CachedIdentity | null;
} | null;
apiById: (Api & { keyAuth: KeyAuth | null }) | null;
keysByOwnerId: {
- key: Key & { encrypted: EncryptedKey | null };
+ key: Key & { encrypted: EncryptedKey | null; ratelimits: Ratelimit[] };
api: Api;
}[];
verificationsByKeyId: {
@@ -58,6 +58,7 @@ export type CacheNamespaces = {
permissions: string[];
roles: string[];
identity: CachedIdentity | null;
+ ratelimits: Ratelimit[];
}
>;
total: number;
diff --git a/apps/api/src/pkg/key_migration/handler.ts b/apps/api/src/pkg/key_migration/handler.ts
index 7ebd4552a4..873c89fe12 100644
--- a/apps/api/src/pkg/key_migration/handler.ts
+++ b/apps/api/src/pkg/key_migration/handler.ts
@@ -83,11 +83,18 @@ export async function migrateKey(
refillDay: message.refill?.refillDay,
enabled: message.enabled,
remaining: message.remaining,
- ratelimitAsync: message.ratelimit?.async,
- ratelimitLimit: message.ratelimit?.limit,
- ratelimitDuration: message.ratelimit?.duration,
environment: message.environment,
});
+ if (message.ratelimit) {
+ await tx.insert(schema.ratelimits).values({
+ id: newId("ratelimit"),
+ workspaceId: message.workspaceId,
+ keyId: keyId,
+ limit: message.ratelimit.limit,
+ duration: message.ratelimit.duration,
+ name: "default",
+ });
+ }
if (message.encrypted) {
await tx.insert(schema.encryptedKeys).values({
diff --git a/apps/api/src/pkg/keys/service.ts b/apps/api/src/pkg/keys/service.ts
index 9616f592c9..d01b5fb087 100644
--- a/apps/api/src/pkg/keys/service.ts
+++ b/apps/api/src/pkg/keys/service.ts
@@ -287,19 +287,10 @@ export class KeyService {
* Merge ratelimits from the identity and the key
* Key limits take pecedence
*/
- const ratelimits: { [name: string]: Pick } = {};
-
- if (
- dbRes.ratelimitAsync !== null &&
- dbRes.ratelimitDuration !== null &&
- dbRes.ratelimitLimit !== null
- ) {
- ratelimits.default = {
- name: "default",
- limit: dbRes.ratelimitLimit,
- duration: dbRes.ratelimitDuration,
- };
- }
+ const ratelimits: {
+ [name: string]: Pick;
+ } = {};
+
for (const rl of dbRes.identity?.ratelimits ?? []) {
ratelimits[rl.name] = rl;
}
@@ -553,16 +544,19 @@ export class KeyService {
*/
const ratelimits: {
- [name: string | "default"]: Required;
+ [name: string]: Required;
} = {};
- if (data.ratelimits && "default" in data.ratelimits && typeof req.ratelimits === "undefined") {
- ratelimits.default = {
- identity: data.key.id,
- name: data.ratelimits.default.name,
- cost: req.ratelimit?.cost ?? DEFAULT_RATELIMIT_COST,
- limit: data.ratelimits.default.limit,
- duration: data.ratelimits.default.duration,
- };
+
+ for (const rl of Object.values(data.ratelimits)) {
+ if (rl.autoApply) {
+ ratelimits[rl.name] = {
+ identity: data.identity?.id ?? data.key.id,
+ name: rl.name,
+ cost: DEFAULT_RATELIMIT_COST,
+ limit: rl.limit,
+ duration: rl.duration,
+ };
+ }
}
for (const r of req.ratelimits ?? []) {
@@ -681,7 +675,7 @@ export class KeyService {
private async ratelimit(
c: Context,
key: Key,
- ratelimits: { [name: string | "default"]: Required },
+ ratelimits: { [name: string]: Required },
): Promise<[boolean, VerifyKeyResult["ratelimit"]]> {
if (Object.keys(ratelimits).length === 0) {
return [true, undefined];
@@ -695,7 +689,7 @@ export class KeyService {
c,
Object.values(ratelimits).map((r) => ({
name: r.name,
- async: !!key.ratelimitAsync,
+ async: false,
workspaceId: key.workspaceId,
identifier: r.identity,
cost: r.cost,
diff --git a/apps/api/src/routes/legacy_apis_listKeys.ts b/apps/api/src/routes/legacy_apis_listKeys.ts
index a7458862c9..32aabf1844 100644
--- a/apps/api/src/routes/legacy_apis_listKeys.ts
+++ b/apps/api/src/routes/legacy_apis_listKeys.ts
@@ -117,6 +117,9 @@ export const registerLegacyApisListKeys = (app: App) =>
limit: limit,
orderBy: schema.keys.id,
offset: offset ? offset : undefined,
+ with: {
+ ratelimits: true,
+ },
});
metrics.emit({
metric: "metric.db.read",
@@ -130,29 +133,30 @@ export const registerLegacyApisListKeys = (app: App) =>
.where(and(eq(schema.keys.keyAuthId, api.keyAuthId), isNull(schema.keys.deletedAtM)));
return c.json({
- keys: keys.map((k) => ({
- id: k.id,
- start: k.start,
- apiId: api.id,
- workspaceId: k.workspaceId,
- name: k.name ?? undefined,
- ownerId: k.ownerId ?? undefined,
- meta: k.meta ? JSON.parse(k.meta) : undefined,
- createdAt: k.createdAtM ?? undefined,
- expires: k.expires?.getTime() ?? undefined,
- ratelimit:
- k.ratelimitAsync !== null && k.ratelimitLimit !== null && k.ratelimitDuration !== null
+ keys: keys.map((k) => {
+ const ratelimit = k.ratelimits.find((rl) => rl.name === "default");
+ return {
+ id: k.id,
+ start: k.start,
+ apiId: api.id,
+ workspaceId: k.workspaceId,
+ name: k.name ?? undefined,
+ ownerId: k.ownerId ?? undefined,
+ meta: k.meta ? JSON.parse(k.meta) : undefined,
+ createdAt: k.createdAtM ?? undefined,
+ expires: k.expires?.getTime() ?? undefined,
+ ratelimit: ratelimit
? {
- async: k.ratelimitAsync,
- type: k.ratelimitAsync ? "fast" : ("consistent" as unknown),
- limit: k.ratelimitLimit,
- duration: k.ratelimitDuration,
- refillRate: k.ratelimitLimit,
- refillInterval: k.ratelimitDuration,
+ async: false,
+ limit: ratelimit.limit,
+ duration: ratelimit.duration,
+ refillRate: ratelimit.limit,
+ refillInterval: ratelimit.duration,
}
: undefined,
- remaining: k.remaining ?? undefined,
- })),
+ remaining: k.remaining ?? undefined,
+ };
+ }),
// @ts-ignore, mysql sucks
total: Number.parseInt(total.at(0)?.count ?? "0"),
cursor: keys.at(-1)?.id ?? undefined,
diff --git a/apps/api/src/routes/legacy_keys_createKey.ts b/apps/api/src/routes/legacy_keys_createKey.ts
index 32c32d7f7d..9a7a5e3423 100644
--- a/apps/api/src/routes/legacy_keys_createKey.ts
+++ b/apps/api/src/routes/legacy_keys_createKey.ts
@@ -221,12 +221,20 @@ export const registerLegacyKeysCreate = (app: App) =>
forWorkspaceId: null,
expires: req.expires ? new Date(req.expires) : null,
createdAtM: Date.now(),
- ratelimitLimit: req.ratelimit?.limit,
- ratelimitDuration: req.ratelimit?.refillRate,
- ratelimitAsync: req.ratelimit?.type === "fast",
remaining: req.remaining,
deletedAtM: null,
});
+ if (req.ratelimit) {
+ await tx.insert(schema.ratelimits).values({
+ id: newId("ratelimit"),
+ workspaceId: authorizedWorkspaceId,
+ name: "default",
+ limit: req.ratelimit.limit,
+ duration: req.ratelimit.refillRate,
+ keyId: keyId,
+ });
+ }
+
await insertUnkeyAuditLog(c, tx, {
workspaceId: authorizedWorkspaceId,
actor: { type: "key", id: rootKeyId },
diff --git a/apps/api/src/routes/v1_apis_listKeys.ts b/apps/api/src/routes/v1_apis_listKeys.ts
index 70a875919d..5b0dd077e6 100644
--- a/apps/api/src/routes/v1_apis_listKeys.ts
+++ b/apps/api/src/routes/v1_apis_listKeys.ts
@@ -200,6 +200,7 @@ export const registerV1ApisListKeys = (app: App) =>
permission: true,
},
},
+ ratelimits: true,
},
limit: limit,
orderBy: schema.keys.id,
@@ -233,6 +234,7 @@ export const registerV1ApisListKeys = (app: App) =>
: null,
permissions: Array.from(permissions.values()),
roles: k.roles.map((r) => r.role.name),
+ ratelimits: k.ratelimits,
};
}),
total: keySpace.sizeApprox,
@@ -293,50 +295,53 @@ export const registerV1ApisListKeys = (app: App) =>
}),
);
}
+
return c.json({
- keys: data.keys.map((k) => ({
- id: k.id,
- start: k.start,
- apiId: api.id,
- workspaceId: k.workspaceId,
- name: k.name ?? undefined,
- ownerId: k.ownerId ?? undefined,
- meta: k.meta ? JSON.parse(k.meta) : undefined,
- createdAt: k.createdAtM ?? undefined,
- updatedAt: k.updatedAtM ?? undefined,
- expires: k.expires?.getTime() ?? undefined,
- ratelimit:
- k.ratelimitAsync !== null && k.ratelimitLimit !== null && k.ratelimitDuration !== null
+ keys: data.keys.map((k) => {
+ const ratelimit = k.ratelimits.find((rl) => rl.name === "default");
+ return {
+ id: k.id,
+ start: k.start,
+ apiId: api.id,
+ workspaceId: k.workspaceId,
+ name: k.name ?? undefined,
+ ownerId: k.ownerId ?? undefined,
+ meta: k.meta ? JSON.parse(k.meta) : undefined,
+ createdAt: k.createdAtM ?? undefined,
+ updatedAt: k.updatedAtM ?? undefined,
+ expires: k.expires?.getTime() ?? undefined,
+ ratelimit: ratelimit
+ ? {
+ async: false,
+ limit: ratelimit.limit,
+ duration: ratelimit.duration,
+ refillRate: ratelimit.limit,
+ refillInterval: ratelimit.duration,
+ }
+ : undefined,
+
+ remaining: k.remaining ?? undefined,
+ refill: k.refillAmount
? {
- async: k.ratelimitAsync,
- type: k.ratelimitAsync ? "fast" : ("consistent" as unknown),
- limit: k.ratelimitLimit,
- duration: k.ratelimitDuration,
- refillRate: k.ratelimitLimit,
- refillInterval: k.ratelimitDuration,
+ interval: k.refillDay ? ("monthly" as const) : ("daily" as const),
+ amount: k.refillAmount,
+ refillDay: k.refillDay,
+ lastRefillAt: k.lastRefillAt?.getTime(),
}
: undefined,
- remaining: k.remaining ?? undefined,
- refill: k.refillAmount
- ? {
- interval: k.refillDay ? ("monthly" as const) : ("daily" as const),
- amount: k.refillAmount,
- refillDay: k.refillDay,
- lastRefillAt: k.lastRefillAt?.getTime(),
- }
- : undefined,
- environment: k.environment ?? undefined,
- plaintext: plaintext[k.id] ?? undefined,
- roles: k.roles,
- permissions: k.permissions,
- identity: k.identity
- ? {
- id: k.identity.id,
- externalId: k.identity.externalId,
- meta: k.identity.meta ?? undefined,
- }
- : undefined,
- })),
+ environment: k.environment ?? undefined,
+ plaintext: plaintext[k.id] ?? undefined,
+ roles: k.roles,
+ permissions: k.permissions,
+ identity: k.identity
+ ? {
+ id: k.identity.id,
+ externalId: k.identity.externalId,
+ meta: k.identity.meta ?? undefined,
+ }
+ : undefined,
+ };
+ }),
total: data.total,
cursor: data.keys.at(-1)?.id ?? undefined,
});
diff --git a/apps/api/src/routes/v1_keys_createKey.ts b/apps/api/src/routes/v1_keys_createKey.ts
index 9834b0bdf2..28b7cceb87 100644
--- a/apps/api/src/routes/v1_keys_createKey.ts
+++ b/apps/api/src/routes/v1_keys_createKey.ts
@@ -373,9 +373,6 @@ export const registerV1KeysCreateKey = (app: App) =>
expires: req.expires ? new Date(req.expires) : null,
createdAtM: Date.now(),
updatedAtM: null,
- ratelimitAsync: req.ratelimit?.async ?? req.ratelimit?.type === "fast",
- ratelimitLimit: req.ratelimit?.limit ?? req.ratelimit?.refillRate,
- ratelimitDuration: req.ratelimit?.duration ?? req.ratelimit?.refillInterval,
remaining: req.remaining,
refillDay: req.refill?.interval === "daily" ? null : (req?.refill?.refillDay ?? 1),
refillAmount: req.refill?.amount,
@@ -384,6 +381,19 @@ export const registerV1KeysCreateKey = (app: App) =>
environment: req.environment ?? null,
identityId: identity?.id,
});
+ if (req.ratelimit) {
+ await db.primary.insert(schema.ratelimits).values({
+ id: newId("ratelimit"),
+ keyId: kId,
+ limit: req.ratelimit.limit ?? req.ratelimit.refillRate!,
+ duration: req.ratelimit.duration ?? req.ratelimit.refillInterval!,
+ workspaceId: authorizedWorkspaceId,
+ name: "default",
+ autoApply: true,
+ identityId: null,
+ createdAt: Date.now(),
+ });
+ }
if (req.recoverable && api.keyAuth?.storeEncryptedKeys) {
const perm = rbac.evaluatePermissions(
diff --git a/apps/api/src/routes/v1_keys_deleteKey.ts b/apps/api/src/routes/v1_keys_deleteKey.ts
index 35f44c2025..ce5bdd8206 100644
--- a/apps/api/src/routes/v1_keys_deleteKey.ts
+++ b/apps/api/src/routes/v1_keys_deleteKey.ts
@@ -81,6 +81,7 @@ export const registerV1KeysDeleteKey = (app: App) =>
api: true,
},
},
+ ratelimits: true,
},
});
if (!dbRes) {
@@ -98,6 +99,7 @@ export const registerV1KeysDeleteKey = (app: App) =>
meta: dbRes.identity.meta,
}
: null,
+ ratelimits: dbRes.ratelimits,
};
});
diff --git a/apps/api/src/routes/v1_keys_getKey.ts b/apps/api/src/routes/v1_keys_getKey.ts
index 89d7e45dce..123477496d 100644
--- a/apps/api/src/routes/v1_keys_getKey.ts
+++ b/apps/api/src/routes/v1_keys_getKey.ts
@@ -60,6 +60,7 @@ export const registerV1KeysGetKey = (app: App) =>
},
},
identity: true,
+ ratelimits: true,
},
});
if (!dbRes) {
@@ -70,6 +71,7 @@ export const registerV1KeysGetKey = (app: App) =>
api: dbRes.keyAuth.api,
permissions: dbRes.permissions.map((p) => p.permission.name),
roles: dbRes.roles.map((p) => p.role.name),
+ ratelimits: dbRes.ratelimits,
identity: dbRes.identity
? {
id: dbRes.identity.id,
@@ -138,6 +140,8 @@ export const registerV1KeysGetKey = (app: App) =>
}
}
+ const ratelimit = key.ratelimits.find((rl) => rl.name === "default");
+
return c.json({
id: key.id,
start: key.start,
@@ -158,17 +162,15 @@ export const registerV1KeysGetKey = (app: App) =>
lastRefillAt: key.lastRefillAt?.getTime(),
}
: undefined,
- ratelimit:
- key.ratelimitAsync !== null && key.ratelimitLimit !== null && key.ratelimitDuration !== null
- ? {
- async: key.ratelimitAsync,
- type: key.ratelimitAsync ? "fast" : ("consistent" as unknown),
- limit: key.ratelimitLimit,
- duration: key.ratelimitDuration,
- refillRate: key.ratelimitLimit,
- refillInterval: key.ratelimitDuration,
- }
- : undefined,
+ ratelimit: ratelimit
+ ? {
+ async: false,
+ limit: ratelimit.limit,
+ duration: ratelimit.duration,
+ refillRate: ratelimit.limit,
+ refillInterval: ratelimit.duration,
+ }
+ : undefined,
roles: data.roles,
permissions: data.permissions,
enabled: key.enabled,
diff --git a/apps/api/src/routes/v1_keys_getVerifications.ts b/apps/api/src/routes/v1_keys_getVerifications.ts
index 9ede996662..99f3a4f06e 100644
--- a/apps/api/src/routes/v1_keys_getVerifications.ts
+++ b/apps/api/src/routes/v1_keys_getVerifications.ts
@@ -106,6 +106,7 @@ export const registerV1KeysGetVerifications = (app: App) =>
api: true,
},
},
+ ratelimits: true,
},
});
if (!dbRes) {
@@ -124,6 +125,7 @@ export const registerV1KeysGetVerifications = (app: App) =>
meta: dbRes.identity.meta,
}
: null,
+ ratelimits: dbRes.ratelimits,
};
});
@@ -165,12 +167,13 @@ export const registerV1KeysGetVerifications = (app: App) =>
api: true,
},
},
+ ratelimits: true,
},
});
if (!dbRes) {
return [];
}
- return dbRes.map((key) => ({ key, api: key.keyAuth.api }));
+ return dbRes.map((key) => ({ key, api: key.keyAuth.api, ratelimits: key.ratelimits }));
});
if (keys.err) {
throw new UnkeyApiError({
diff --git a/apps/api/src/routes/v1_keys_updateKey.happy.test.ts b/apps/api/src/routes/v1_keys_updateKey.happy.test.ts
index 4903e012fb..35d29b8048 100644
--- a/apps/api/src/routes/v1_keys_updateKey.happy.test.ts
+++ b/apps/api/src/routes/v1_keys_updateKey.happy.test.ts
@@ -88,15 +88,19 @@ test("update all", async (t) => {
const found = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, key.id),
+ with: {
+ ratelimits: true,
+ },
});
expect(found).toBeDefined();
expect(found?.name).toEqual("newName");
expect(found?.ownerId).toEqual("newOwnerId");
expect(found?.meta).toEqual(JSON.stringify({ new: "meta" }));
- expect(found?.ratelimitAsync).toEqual(true);
- expect(found?.ratelimitLimit).toEqual(10);
- expect(found?.ratelimitDuration).toEqual(1000);
expect(found?.remaining).toEqual(0);
+ expect(found!.ratelimits.length).toBe(1);
+ expect(found!.ratelimits[0].name).toBe("default");
+ expect(found!.ratelimits[0].limit).toBe(10);
+ expect(found!.ratelimits[0].duration).toBe(1000);
});
test("update ratelimit", async (t) => {
@@ -136,15 +140,19 @@ test("update ratelimit", async (t) => {
const found = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, key.id),
+ with: {
+ ratelimits: true,
+ },
});
expect(found).toBeDefined();
expect(found?.name).toEqual("test");
expect(found?.ownerId).toBeNull();
expect(found?.meta).toBeNull();
- expect(found?.ratelimitAsync).toEqual(true);
- expect(found?.ratelimitLimit).toEqual(10);
- expect(found?.ratelimitDuration).toEqual(1000);
expect(found?.remaining).toBeNull();
+ expect(found!.ratelimits.length).toBe(1);
+ expect(found!.ratelimits[0].name).toBe("default");
+ expect(found!.ratelimits[0].limit).toBe(10);
+ expect(found!.ratelimits[0].duration).toBe(1000);
});
describe("update roles", () => {
@@ -823,16 +831,17 @@ test("update should not affect undefined fields", async (t) => {
const found = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, key.id),
+ with: {
+ ratelimits: true,
+ },
});
expect(found).toBeDefined();
expect(found?.name).toEqual("test");
expect(found?.ownerId).toEqual("newOwnerId");
expect(found?.meta).toBeNull();
expect(found?.expires).toEqual(key.expires);
- expect(found?.ratelimitAsync).toBeNull();
- expect(found?.ratelimitLimit).toBeNull();
- expect(found?.ratelimitDuration).toBeNull();
expect(found?.remaining).toBeNull();
+ expect(found!.ratelimits.length).toBe(0);
});
test("update enabled true", async (t) => {
@@ -993,12 +1002,16 @@ test("update ratelimit should not disable it", async (t) => {
const found = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, key.body.keyId),
+ with: {
+ ratelimits: true,
+ },
});
expect(found).toBeDefined();
- expect(found!.ratelimitAsync).toEqual(true);
- expect(found!.ratelimitLimit).toEqual(5);
- expect(found!.ratelimitDuration).toEqual(5000);
+ expect(found!.ratelimits.length).toBe(1);
+ expect(found!.ratelimits[0].name).toBe("default");
+ expect(found!.ratelimits[0].limit).toBe(5);
+ expect(found!.ratelimits[0].duration).toBe(5000);
const verify = await h.post({
url: "/v1/keys.verifyKey",
@@ -1106,12 +1119,16 @@ describe("update name", () => {
const found = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, key.body.keyId),
+ with: {
+ ratelimits: true,
+ },
});
expect(found).toBeDefined();
- expect(found!.ratelimitAsync).toEqual(true);
- expect(found!.ratelimitLimit).toEqual(10);
- expect(found!.ratelimitDuration).toEqual(1000);
+ expect(found!.ratelimits.length).toBe(1);
+ expect(found!.ratelimits[0].name).toBe("default");
+ expect(found!.ratelimits[0].limit).toBe(10);
+ expect(found!.ratelimits[0].duration).toBe(1000);
const verify = await h.post({
url: "/v1/keys.verifyKey",
diff --git a/apps/api/src/routes/v1_keys_updateKey.ts b/apps/api/src/routes/v1_keys_updateKey.ts
index 0eb8419ec1..3809c3c358 100644
--- a/apps/api/src/routes/v1_keys_updateKey.ts
+++ b/apps/api/src/routes/v1_keys_updateKey.ts
@@ -4,8 +4,9 @@ import { createRoute, z } from "@hono/zod-openapi";
import { insertUnkeyAuditLog } from "@/pkg/audit";
import { rootKeyAuth } from "@/pkg/auth/root_key";
import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors";
-import { type Key, schema } from "@unkey/db";
+import { type Key, and, schema } from "@unkey/db";
import { eq } from "@unkey/db";
+import { newId } from "@unkey/id";
import { buildUnkeyQuery } from "@unkey/rbac";
import { upsertIdentity } from "./v1_keys_createKey";
import { setPermissions } from "./v1_keys_setPermissions";
@@ -289,6 +290,7 @@ export const registerV1KeysUpdate = (app: App) =>
api: true,
},
},
+ ratelimits: true,
},
});
@@ -374,16 +376,36 @@ export const registerV1KeysUpdate = (app: App) =>
}
if (typeof req.ratelimit !== "undefined") {
if (req.ratelimit === null) {
- changes.ratelimitAsync = null;
- changes.ratelimitLimit = null;
- changes.ratelimitDuration = null;
+ await db.primary
+ .delete(schema.ratelimits)
+ .where(
+ and(
+ eq(schema.ratelimits.workspaceId, auth.authorizedWorkspaceId),
+ eq(schema.ratelimits.name, "default"),
+ eq(schema.ratelimits.keyId, key.id),
+ ),
+ );
} else {
- changes.ratelimitAsync =
- typeof req.ratelimit.async === "boolean"
- ? req.ratelimit.async
- : req.ratelimit.type === "fast";
- changes.ratelimitLimit = req.ratelimit.limit ?? req.ratelimit.refillRate;
- changes.ratelimitDuration = req.ratelimit.duration ?? req.ratelimit.refillInterval;
+ const existing = key.ratelimits.find((r) => r.name === "default");
+ if (existing) {
+ await db.primary
+ .update(schema.ratelimits)
+ .set({
+ limit: req.ratelimit.limit ?? req.ratelimit.refillRate,
+ duration: req.ratelimit.duration ?? req.ratelimit.refillInterval,
+ })
+ .where(and(eq(schema.ratelimits.id, existing.id)));
+ } else {
+ await db.primary.insert(schema.ratelimits).values({
+ id: newId("ratelimit"),
+ workspaceId: auth.authorizedWorkspaceId,
+ name: "default",
+ keyId: key.id,
+ limit: req.ratelimit.limit ?? req.ratelimit.refillRate!,
+ duration: req.ratelimit.duration ?? req.ratelimit.refillInterval!,
+ autoApply: true,
+ });
+ }
}
}
diff --git a/apps/api/src/routes/v1_keys_verifyKey.multilimit.test.ts b/apps/api/src/routes/v1_keys_verifyKey.multilimit.test.ts
index fffcfaa89f..50278c2357 100644
--- a/apps/api/src/routes/v1_keys_verifyKey.multilimit.test.ts
+++ b/apps/api/src/routes/v1_keys_verifyKey.multilimit.test.ts
@@ -706,17 +706,26 @@ describe("with identity", () => {
});
const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString();
+ const keyId = newId("test");
await h.db.primary.insert(schema.keys).values({
- id: newId("test"),
+ id: keyId,
keyAuthId: h.resources.userKeyAuth.id,
hash: await sha256(key),
start: key.slice(0, 8),
workspaceId: h.resources.userWorkspace.id,
createdAtM: Date.now(),
identityId,
- ratelimitAsync: true,
- ratelimitLimit: 1,
- ratelimitDuration: 20_000,
+ });
+
+ await h.db.primary.insert(schema.ratelimits).values({
+ id: newId("test"),
+ workspaceId: h.resources.userWorkspace.id,
+ keyId: keyId,
+ limit: 1,
+ duration: 20_000,
+ autoApply: true,
+ identityId: null,
+ name: "default",
});
const testCases: TestCase[] = [
diff --git a/apps/api/src/routes/v1_keys_verifyKey.test.ts b/apps/api/src/routes/v1_keys_verifyKey.test.ts
index 25859454a5..ce2df35ddc 100644
--- a/apps/api/src/routes/v1_keys_verifyKey.test.ts
+++ b/apps/api/src/routes/v1_keys_verifyKey.test.ts
@@ -358,8 +358,9 @@ describe("when ratelimited", () => {
workspaceId: h.resources.userWorkspace.id,
});
+ const keyId = newId("test");
await h.db.primary.insert(schema.keys).values({
- id: newId("test"),
+ id: keyId,
keyAuthId: h.resources.userKeyAuth.id,
hash: await sha256(key),
identityId,
@@ -367,9 +368,17 @@ describe("when ratelimited", () => {
start: key.slice(0, 8),
workspaceId: h.resources.userWorkspace.id,
createdAtM: Date.now(),
- ratelimitAsync: false,
- ratelimitLimit: 0,
- ratelimitDuration: 60_000,
+ });
+
+ await h.db.primary.insert(schema.ratelimits).values({
+ id: newId("test"),
+ workspaceId: h.resources.userWorkspace.id,
+ keyId: keyId,
+ limit: 0,
+ duration: 60_000,
+ autoApply: true,
+ identityId: null,
+ name: "default",
});
const res = await h.post({
@@ -396,16 +405,24 @@ describe("with ratelimit override", () => {
test("deducts the correct number of tokens", { timeout: 20000 }, async (t) => {
const h = await IntegrationHarness.init(t);
const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString();
+ const keyId = newId("test");
await h.db.primary.insert(schema.keys).values({
- id: newId("test"),
+ id: keyId,
keyAuthId: h.resources.userKeyAuth.id,
hash: await sha256(key),
start: key.slice(0, 8),
workspaceId: h.resources.userWorkspace.id,
createdAtM: Date.now(),
- ratelimitLimit: 10,
- ratelimitDuration: 60_000,
- ratelimitAsync: false,
+ });
+ await h.db.primary.insert(schema.ratelimits).values({
+ id: newId("test"),
+ workspaceId: h.resources.userWorkspace.id,
+ keyId: keyId,
+ limit: 10,
+ duration: 60_000,
+ autoApply: true,
+ identityId: null,
+ name: "default",
});
const res = await h.post({
@@ -431,16 +448,24 @@ describe("with default ratelimit", () => {
test("uses the on-key defined settings", { timeout: 20000 }, async (t) => {
const h = await IntegrationHarness.init(t);
const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString();
+ const keyId = newId("test");
await h.db.primary.insert(schema.keys).values({
- id: newId("test"),
+ id: keyId,
keyAuthId: h.resources.userKeyAuth.id,
hash: await sha256(key),
start: key.slice(0, 8),
workspaceId: h.resources.userWorkspace.id,
createdAtM: Date.now(),
- ratelimitLimit: 10,
- ratelimitDuration: 60_000,
- ratelimitAsync: false,
+ });
+ await h.db.primary.insert(schema.ratelimits).values({
+ id: newId("test"),
+ workspaceId: h.resources.userWorkspace.id,
+ keyId: keyId,
+ limit: 10,
+ duration: 60_000,
+ autoApply: true,
+ identityId: null,
+ name: "default",
});
const res = await h.post({
@@ -539,9 +564,6 @@ describe("with ratelimit", () => {
start: key.slice(0, 8),
workspaceId: h.resources.userWorkspace.id,
createdAtM: Date.now(),
- ratelimitLimit: 10,
- ratelimitDuration: 60_000,
- ratelimitAsync: false,
});
const res = await h.post({
@@ -570,17 +592,26 @@ describe("with ratelimit", () => {
async (t) => {
const h = await IntegrationHarness.init(t);
const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString();
+ const keyId = newId("test");
await h.db.primary.insert(schema.keys).values({
- id: newId("test"),
+ id: keyId,
keyAuthId: h.resources.userKeyAuth.id,
hash: await sha256(key),
start: key.slice(0, 8),
workspaceId: h.resources.userWorkspace.id,
createdAtM: Date.now(),
remaining: 0,
- ratelimitLimit: 10,
- ratelimitDuration: 60_000,
- ratelimitAsync: false,
+ });
+
+ await h.db.primary.insert(schema.ratelimits).values({
+ id: newId("test"),
+ workspaceId: h.resources.userWorkspace.id,
+ keyId: keyId,
+ limit: 10,
+ duration: 60_000,
+ autoApply: true,
+ identityId: null,
+ name: "default",
});
const res = await h.post({
diff --git a/apps/api/src/routes/v1_migrations_createKey.ts b/apps/api/src/routes/v1_migrations_createKey.ts
index 44de7e65b0..6f4802f7e4 100644
--- a/apps/api/src/routes/v1_migrations_createKey.ts
+++ b/apps/api/src/routes/v1_migrations_createKey.ts
@@ -469,9 +469,6 @@ export const registerV1MigrationsCreateKeys = (app: App) =>
workspaceId: authorizedWorkspaceId,
forWorkspaceId: null,
expires: key.expires ? new Date(key.expires) : null,
- ratelimitAsync: key.ratelimit?.async ?? key.ratelimit?.type === "fast",
- ratelimitLimit: key.ratelimit?.limit ?? key.ratelimit?.refillRate ?? null,
- ratelimitDuration: key.ratelimit?.refillInterval ?? key.ratelimit?.refillInterval ?? null,
remaining: key.remaining ?? null,
refillDay: key.refill?.interval === "daily" ? null : (key?.refill?.refillDay ?? 1),
refillAmount: key.refill?.amount ?? null,
diff --git a/apps/api/src/routes/v1_migrations_enqueueKeys.happy.test_disabled.ts b/apps/api/src/routes/v1_migrations_enqueueKeys.happy.test_disabled.ts
index 695a04825d..36417e1210 100644
--- a/apps/api/src/routes/v1_migrations_enqueueKeys.happy.test_disabled.ts
+++ b/apps/api/src/routes/v1_migrations_enqueueKeys.happy.test_disabled.ts
@@ -545,11 +545,14 @@ test("creates a key with ratelimit", async (t) => {
async () => {
const key = await h.db.primary.query.keys.findFirst({
where: (table, { eq }) => eq(table.keyAuthId, h.resources.userKeyAuth.id),
+ with: {
+ ratelimits: true,
+ },
});
expect(key).toBeDefined();
- expect(key!.ratelimitAsync).toBe(ratelimit.async);
- expect(key!.ratelimitLimit).toBe(ratelimit.limit);
- expect(key!.ratelimitDuration).toBe(ratelimit.duration);
+ expect(key!.ratelimits.length).toBe(1);
+ expect(key!.ratelimits[0].limit).toBe(ratelimit.limit);
+ expect(key!.ratelimits[0].duration).toBe(ratelimit.duration);
},
{ timeout: 20000, interval: 500 },
);
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup.tsx
index d048024ecc..63466c47e5 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup.tsx
@@ -1,9 +1,9 @@
"use client";
import { Gauge, Trash } from "@unkey/icons";
-import { Button, FormInput } from "@unkey/ui";
+import { Button, FormCheckbox, FormInput, InlineLink } from "@unkey/ui";
import { cn } from "@unkey/ui/src/lib/utils";
import { useEffect } from "react";
-import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
+import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import type { RatelimitFormValues, RatelimitItem } from "../create-key.schema";
import { ProtectionSwitch } from "./protection-switch";
@@ -34,6 +34,7 @@ export const RatelimitSetup = () => {
name: "Default",
limit: 10,
refillInterval: 1000,
+ autoApply: false,
});
}
}, [fields.length, append]);
@@ -48,6 +49,7 @@ export const RatelimitSetup = () => {
name: "",
limit: 10,
refillInterval: 1000,
+ autoApply: false,
};
append(newItem);
};
@@ -91,7 +93,7 @@ export const RatelimitSetup = () => {
"[&_input:first-of-type]:h-[36px]",
fields.length <= 1 ? "w-full" : "flex-1",
)}
- placeholder="Default"
+ placeholder="my-ratelimit"
type="text"
label="Name"
description="A name to identify this rate limit rule"
@@ -148,6 +150,37 @@ export const RatelimitSetup = () => {
{...register(`ratelimit.data.${index}.refillInterval`)}
/>
+
+ (
+
+ This rate limit rule will always be used.{" "}
+
+ .
+
+ }
+ error={errors.ratelimit?.data?.[index]?.autoApply?.message}
+ disabled={!ratelimitEnabled}
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ name={field.name}
+ ref={field.ref}
+ />
+ )}
+ />
))}
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.schema.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.schema.ts
index 58fd6f13b2..cc75e869fc 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.schema.ts
+++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.schema.ts
@@ -137,6 +137,7 @@ export const ratelimitItemSchema = z.object({
}),
})
.positive({ message: "Limit must be greater than 0" }),
+ autoApply: z.boolean(),
});
export const metadataValidationSchema = z.object({
@@ -258,6 +259,7 @@ export const ratelimitSchema = z.object({
name: "default",
limit: 10,
refillInterval: 1000,
+ autoApply: true,
},
],
}),
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.utils.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.utils.ts
index 42751265b1..75cae1854a 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.utils.ts
+++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.utils.ts
@@ -125,6 +125,7 @@ export const getDefaultValues = (
name: "Default",
limit: 10,
refillInterval: 1000,
+ autoApply: true,
},
],
},
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx
index 82971575f2..bfa55f41ed 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx
@@ -91,10 +91,6 @@ export const KeysOverviewLogDetails = ({
log.key_details.remaining_requests !== null
? log.key_details.remaining_requests
: "Unlimited",
- "Rate Limit": log.key_details.ratelimit_limit
- ? `${log.key_details.ratelimit_limit} per ${log.key_details.ratelimit_duration || "N/A"}ms`
- : "No limit",
- Async: log.key_details.ratelimit_async ? "Yes" : "No",
};
const identity = log.key_details.identity
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx
index 3342214ba5..174b6f4a90 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx
@@ -59,7 +59,6 @@ export const EditRatelimits = ({ keyDetails, isOpen, onClose }: EditRatelimitsPr
try {
await key.mutateAsync({
keyId: keyDetails.id,
- ratelimitType: "v2",
ratelimit: {
enabled: data.ratelimit.enabled,
data: data.ratelimit.data,
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts
index e2b5006c27..ded4b6ef0e 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts
@@ -9,33 +9,21 @@ export const useEditRatelimits = (onSuccess?: () => void) => {
onSuccess(data, variables) {
let description = "";
- // Handle both V1 and V2 ratelimit types
- if (variables.ratelimitType === "v2") {
- if (variables.ratelimit?.enabled) {
- const rulesCount = variables.ratelimit.data.length;
+ if (variables.ratelimit?.enabled) {
+ const rulesCount = variables.ratelimit.data.length;
- if (rulesCount === 1) {
- // If there's just one rule, show its limit directly
- const rule = variables.ratelimit.data[0];
- description = `Your key ${data.keyId} has been updated with a limit of ${
- rule.limit
- } requests per ${formatInterval(rule.refillInterval)}`;
- } else {
- // If there are multiple rules, show the count
- description = `Your key ${data.keyId} has been updated with ${rulesCount} rate limit rules`;
- }
- } else {
- description = `Your key ${data.keyId} has been updated with rate limits disabled`;
- }
- } else {
- // V1 ratelimits
- if (variables.enabled) {
+ if (rulesCount === 1) {
+ // If there's just one rule, show its limit directly
+ const rule = variables.ratelimit.data[0];
description = `Your key ${data.keyId} has been updated with a limit of ${
- variables.ratelimitLimit
- } requests per ${formatInterval(variables.ratelimitDuration || 0)}`;
+ rule.limit
+ } requests per ${formatInterval(rule.refillInterval)}`;
} else {
- description = `Your key ${data.keyId} has been updated with rate limits disabled`;
+ // If there are multiple rules, show the count
+ description = `Your key ${data.keyId} has been updated with ${rulesCount} rate limit rules`;
}
+ } else {
+ description = `Your key ${data.keyId} has been updated with rate limits disabled`;
}
toast.success("Key Ratelimits Updated", {
diff --git a/apps/dashboard/components/dashboard/root-key-table/index.tsx b/apps/dashboard/components/dashboard/root-key-table/index.tsx
index 08ca9e84af..5e4dfe74f1 100644
--- a/apps/dashboard/components/dashboard/root-key-table/index.tsx
+++ b/apps/dashboard/components/dashboard/root-key-table/index.tsx
@@ -33,9 +33,6 @@ type Column = {
expires: Date | null;
ownerId: string | null;
name: string | null;
- ratelimitAsync: boolean | null;
- ratelimitLimit: number | null;
- ratelimitDuration: number | null;
remaining: number | null;
};
diff --git a/apps/dashboard/lib/trpc/routers/api/keys/api-query.ts b/apps/dashboard/lib/trpc/routers/api/keys/api-query.ts
index 34c192ba36..10768233ba 100644
--- a/apps/dashboard/lib/trpc/routers/api/keys/api-query.ts
+++ b/apps/dashboard/lib/trpc/routers/api/keys/api-query.ts
@@ -20,9 +20,6 @@ type DatabaseKey = Pick<
| "meta"
| "enabled"
| "remaining"
- | "ratelimitAsync"
- | "ratelimitLimit"
- | "ratelimitDuration"
| "environment"
| "workspaceId"
> & {
@@ -46,9 +43,6 @@ type KeyDetails = {
meta: DatabaseKey["meta"];
enabled: DatabaseKey["enabled"];
remaining_requests: DatabaseKey["remaining"];
- ratelimit_async: DatabaseKey["ratelimitAsync"];
- ratelimit_limit: DatabaseKey["ratelimitLimit"];
- ratelimit_duration: DatabaseKey["ratelimitDuration"];
environment: DatabaseKey["environment"];
workspace_id: DatabaseKey["workspaceId"];
identity: { external_id: string } | null;
@@ -229,7 +223,7 @@ export async function queryApiKeys({
allIdentityConditions.push(sql`
EXISTS (
- SELECT 1 FROM ${identities}
+ SELECT 1 FROM ${identities}
WHERE ${identities.id} = ${key.identityId}
AND ${condition}
)`);
@@ -271,9 +265,6 @@ export async function queryApiKeys({
meta: true,
enabled: true,
remaining: true,
- ratelimitAsync: true,
- ratelimitLimit: true,
- ratelimitDuration: true,
environment: true,
workspaceId: true,
},
@@ -360,9 +351,6 @@ export function createKeyDetailsMap(keys: DatabaseKey[]): Map;
@@ -57,13 +43,6 @@ export const updateKeyRatelimit = t.procedure
});
}
- if (input.ratelimitType === "v1") {
- return updateRatelimitV1(input, key, {
- audit: ctx.audit,
- userId: ctx.user.id,
- workspaceId: ctx.workspace.id,
- });
- }
return updateRatelimitV2(input, key, {
audit: ctx.audit,
userId: ctx.user.id,
@@ -71,84 +50,6 @@ export const updateKeyRatelimit = t.procedure
});
});
-const updateRatelimitV1 = async (
- input: RatelimitInputSchema,
- key: Key,
- ctx: {
- workspaceId: string;
- userId: string;
- audit: {
- location: string;
- userAgent: string | undefined;
- };
- },
-) => {
- if (input.ratelimitType !== "v1") {
- throw new TRPCError({
- message: "Unsupported rate limit type. Only v1 ratelimits are supported.",
- code: "BAD_REQUEST",
- });
- }
- try {
- await db.transaction(async (tx) => {
- const ratelimitValues = input.enabled
- ? {
- ratelimitAsync: input.ratelimitAsync,
- ratelimitLimit: input.ratelimitLimit,
- ratelimitDuration: input.ratelimitDuration,
- }
- : {
- ratelimitAsync: null,
- ratelimitLimit: null,
- ratelimitDuration: null,
- };
- await tx.update(schema.keys).set(ratelimitValues).where(eq(schema.keys.id, key.id));
-
- const description = input.enabled
- ? `Changed ratelimit of ${key.id}`
- : `Disabled ratelimit of ${key.id}`;
-
- const resources: UnkeyAuditLog["resources"] = [
- {
- type: "key",
- id: key.id,
- name: key.name || undefined,
- meta: input.enabled
- ? {
- "ratelimit.async": input.ratelimitAsync,
- "ratelimit.limit": input.ratelimitLimit,
- "ratelimit.duration": input.ratelimitDuration,
- }
- : undefined,
- },
- ];
- await insertAuditLogs(tx, {
- workspaceId: ctx.workspaceId,
- actor: {
- type: "user",
- id: ctx.userId,
- },
- event: "key.update",
- description,
- resources,
- context: {
- location: ctx.audit.location,
- userAgent: ctx.audit.userAgent,
- },
- });
- });
- } catch (err) {
- console.error(err);
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message:
- "We were unable to update ratelimit on this key. Please try again or contact support@unkey.dev",
- });
- }
-
- return { keyId: key.id };
-};
-
const updateRatelimitV2 = async (
input: RatelimitInputSchema,
key: Key,
@@ -161,12 +62,6 @@ const updateRatelimitV2 = async (
};
},
) => {
- if (input.ratelimitType !== "v2") {
- throw new TRPCError({
- message: "Unsupported rate limit type. Only v2 ratelimits are supported.",
- code: "BAD_REQUEST",
- });
- }
try {
await db.transaction(async (tx) => {
if (input.ratelimit.enabled && input.ratelimit.data.length > 0) {
@@ -198,6 +93,7 @@ const updateRatelimitV2 = async (
limit: ratelimit.limit,
name: ratelimit.name,
updatedAt: Date.now(),
+ autoApply: ratelimit.autoApply,
})
.where(eq(schema.ratelimits.id, ratelimit.id));
} else {
diff --git a/apps/docs/apis/features/ratelimiting/overview.mdx b/apps/docs/apis/features/ratelimiting/overview.mdx
index 2be88ea9de..564bce5c0f 100644
--- a/apps/docs/apis/features/ratelimiting/overview.mdx
+++ b/apps/docs/apis/features/ratelimiting/overview.mdx
@@ -3,26 +3,178 @@ title: Overview
description: "How rate limiting works in unkey"
---
+Unkey's ratelimiting system controls the number of requests a key can make within a given timeframe. This prevents abuse, protects your API from being overwhelmed, and enables usage-based billing models.
-Ratelimits are a way to control the amount of requests a key can make in a given timeframe. This is useful to prevent abuse and to protect your API from being overwhelmed.
+## Multi-Ratelimit System
+Unkey supports multiple named ratelimits per key, providing fine-grained control over different aspects of your API usage. You can define separate limits for different operations, time windows, and use cases within a single key.
+
+The system offers two types of ratelimits: auto-apply ratelimits that check automatically during verification, and manual ratelimits that require explicit specification in requests.
+
+## Auto-Apply vs Manual Ratelimits
+
+Auto-apply ratelimits (`autoApply: true`) are checked automatically during every key verification without needing explicit specification. These work well for general usage limits that should always be enforced.
+
+```bash
+# Create a key with auto-apply ratelimit
+curl -X POST https://api.unkey.dev/v1/keys.createKey \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "apiId": "api_123",
+ "ratelimits": [
+ {
+ "name": "general-requests",
+ "limit": 100,
+ "duration": 60000,
+ "autoApply": true
+ }
+ ]
+ }'
+```
+
+```bash
+# Verify key - auto-apply ratelimits are checked automatically
+curl -X POST https://api.unkey.dev/v1/keys.verifyKey \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "apiId": "api_123",
+ "key": "your_api_key_here"
+ }'
+```
+
+Manual ratelimits (`autoApply: false`) must be explicitly specified in verification requests. Use these for operation-specific limits that only apply to certain endpoints.
+
+```bash
+# Create a key with manual ratelimit
+curl -X POST https://api.unkey.dev/v1/keys.createKey \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "apiId": "api_123",
+ "ratelimits": [
+ {
+ "name": "expensive-operations"
+ }
+ ]
+ }'
+```
+
+```bash
+# Verify key - override cost explicitly
+curl -X POST https://api.unkey.dev/v1/keys.verifyKey \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "apiId": "api_123",
+ "key": "your_api_key_here",
+ "ratelimits": [
+ {
+ "name": "expensive-operations",
+ "cost": 2
+ }
+ ]
+ }'
+```
+
+## Configuration
+
+Configure ratelimits on a per-key or per-identity basis through the dashboard or API. Each ratelimit requires a unique name, limit count, and duration in milliseconds.
+
+```bash
+curl -X POST https://api.unkey.dev/v1/keys.createKey \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "apiId": "api_123",
+ "ratelimits": [
+ {
+ "name": "requests",
+ "limit": 100,
+ "duration": 60000,
+ "autoApply": true
+ },
+ {
+ "name": "daily-quota",
+ "limit": 1000,
+ "duration": 86400000,
+ "autoApply": true
+ }
+ ]
+ }'
+```
+
+You can apply different costs to operations by specifying a cost parameter during verification. This allows resource-intensive operations to consume more of the rate limit than simple operations.
+
+## Migration from Legacy System
-The field `ratelimit` is deprecated. Please use `ratelimits` instead.
+The legacy `ratelimit` field is deprecated. Use the `ratelimits` array instead.
-Ratelimits are configured on a per-key or per-identity basis and can be managed through the dashboard or the API. In order to use a ratelimiting when verifying a key, you need to specify at least the name.
-```json
-{
- // ...
- "ratelimits": [
- {
- "name": "my-ratelimit",
+Existing keys with the legacy `ratelimit` field are automatically migrated to use a special "default" ratelimit with `autoApply: true`. This maintains backward compatibility without requiring code changes.
+
+The legacy format converts automatically:
+
+```bash
+# Legacy format (deprecated)
+curl -X POST https://api.unkey.dev/v1/keys.createKey \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "apiId": "api_123",
+ "ratelimit": {
+ "limit": 100,
+ "duration": 60000
}
- ]
+ }'
+
+# Automatically becomes
+curl -X POST https://api.unkey.dev/v1/keys.createKey \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "apiId": "api_123",
+ "ratelimits": [
+ {
+ "name": "default",
+ "limit": 100,
+ "duration": 60000,
+ "autoApply": true
+ }
+ ]
+ }'
```
+Remove the automatically migrated "default" ratelimit through the key settings in the dashboard or via the updateKey API endpoint if you no longer need it.
+
+## Identity-Level Ratelimits
+
+Configure ratelimits at the identity level to share limits across multiple keys. Identity ratelimits work alongside key-level ratelimits, with key-level settings taking precedence for naming conflicts.
+
+```bash
+curl -X POST https://api.unkey.dev/v1/identities.createIdentity \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "externalId": "user_123",
+ "ratelimits": [
+ {
+ "name": "user-requests",
+ "limit": 1000,
+ "duration": 3600000
+ }
+ ]
+ }'
+```
+
+This approach works well for user-based quotas where multiple API keys should share the same usage limits.
+
+## Next Steps
+
+Learn about [Multiple Ratelimits](/ratelimiting/multi-ratelimits) for advanced configuration patterns and best practices.
-If you do not specify at least one ratelimit, the request will not get ratelimited.
+Understand [Consistency vs Latency](/ratelimiting/modes) trade-offs for different ratelimiting modes.
-As part of the migration towards the new ratelimiting system, there is a magic ratelimit called `default` that will be applied to all requests automatically. The default ratelimit is the one you have previously configured on the key itself. If you want to disable this, please got to the key's settings and delete the limit or use the [updateKey endpoint](https://www.unkey.com/docs/api-reference/keys/update)
+Explore [Ratelimit Overrides](/ratelimiting/overrides) for dynamic limit adjustments.
diff --git a/internal/clickhouse/src/keys/keys.ts b/internal/clickhouse/src/keys/keys.ts
index a2029d9170..3d902686f5 100644
--- a/internal/clickhouse/src/keys/keys.ts
+++ b/internal/clickhouse/src/keys/keys.ts
@@ -89,9 +89,6 @@ export const keyDetailsResponseSchema = z.object({
meta: z.string().nullable(),
enabled: z.boolean(),
remaining_requests: z.number().nullable(),
- ratelimit_async: z.boolean().nullable(),
- ratelimit_limit: z.number().nullable(),
- ratelimit_duration: z.number().nullable(),
environment: z.string().nullable(),
workspace_id: z.string(),
identity: identitySchema.nullable(),
@@ -234,7 +231,7 @@ export function getKeysOverviewLogs(ch: Querier) {
const extendedParamsSchema = keysOverviewLogsParams.extend(paramSchemaExtension);
const query = ch.query({
query: `
-WITH
+WITH
-- First CTE: Filter raw verification records based on conditions from client
filtered_keys AS (
SELECT
@@ -256,7 +253,7 @@ WITH
-- Second CTE: Calculate per-key aggregated metrics
-- This groups all verifications by key_id to get summary counts and most recent activity
aggregated_data AS (
- SELECT
+ SELECT
key_id,
-- Find the timestamp of the latest verification for this key
max(time) as last_request_time,
@@ -281,7 +278,7 @@ WITH
GROUP BY key_id, outcome
)
-- Main query: Join the aggregated data with detailed outcome counts
- SELECT
+ SELECT
a.key_id,
a.last_request_time as time,
a.last_request_id as request_id,
@@ -293,7 +290,7 @@ WITH
FROM aggregated_data a
LEFT JOIN outcome_counts o ON a.key_id = o.key_id
-- Group by all non-aggregated fields to allow the groupArray operation
- GROUP BY
+ GROUP BY
a.key_id,
a.last_request_time,
a.last_request_id,
diff --git a/internal/db/src/schema/identity.ts b/internal/db/src/schema/identity.ts
index 4f5f64c069..addbb6da1a 100644
--- a/internal/db/src/schema/identity.ts
+++ b/internal/db/src/schema/identity.ts
@@ -71,10 +71,18 @@ export const ratelimits = mysqlTable(
limit: int("limit").notNull(),
// milliseconds
duration: bigint("duration", { mode: "number" }).notNull(),
+
+ // if enabled we will use this limit when verifying a key, whether they
+ // specified the name in the request or not
+ autoApply: boolean("auto_apply").notNull().default(false),
},
(table) => ({
nameIdx: index("name_idx").on(table.name),
- uniqueName: uniqueIndex("unique_name_idx").on(table.name, table.keyId, table.identityId),
+ uniqueNamePerKey: uniqueIndex("unique_name_per_key_idx").on(table.name, table.keyId),
+ uniqueNamePerIdentity: uniqueIndex("unique_name_per_identity_idx").on(
+ table.name,
+ table.identityId,
+ ),
identityId: index("identity_id_idx").on(table.identityId),
keyId: index("key_id_idx").on(table.keyId),
}),
diff --git a/tools/migrate/ratelimit-migrate.ts b/tools/migrate/ratelimit-migrate.ts
new file mode 100644
index 0000000000..1340f565c9
--- /dev/null
+++ b/tools/migrate/ratelimit-migrate.ts
@@ -0,0 +1,65 @@
+import { mysqlDrizzle, schema } from "@unkey/db";
+import { newId } from "@unkey/id";
+import mysql from "mysql2/promise";
+
+async function main() {
+ const conn = await mysql.createConnection(
+ `mysql://${process.env.DATABASE_USERNAME}:${process.env.DATABASE_PASSWORD}@${process.env.DATABASE_HOST}:3306/unkey?ssl={}`,
+ );
+
+ await conn.ping();
+ const db = mysqlDrizzle(conn, { schema, mode: "default" });
+
+ let cursor = "";
+ do {
+ const keys = await db.query.keys.findMany({
+ where: (table, { isNotNull, gt, and }) =>
+ and(
+ gt(table.id, cursor),
+ isNotNull(table.ratelimitLimit),
+ isNotNull(table.ratelimitDuration),
+ ),
+ with: {
+ ratelimits: true,
+ },
+ limit: 1000,
+ orderBy: (table, { asc }) => asc(table.id),
+ });
+
+ cursor = keys.at(-1)?.id ?? "";
+ console.info({ cursor, keys: keys.length });
+
+ if (keys.length === 0) {
+ break;
+ }
+
+ for (const key of keys) {
+ if (key.ratelimits.find((rl) => rl.name === "default" && rl.autoApply)) {
+ break;
+ }
+
+ console.info("Updating", key.id, key.ratelimitLimit, key.ratelimitDuration);
+ await db
+ .insert(schema.ratelimits)
+ .values({
+ id: newId("ratelimit"),
+ keyId: key.id,
+ limit: key.ratelimitLimit!,
+ duration: key.ratelimitDuration!,
+ workspaceId: key.workspaceId,
+ name: "default",
+ autoApply: true,
+ })
+ .onDuplicateKeyUpdate({
+ set: {
+ limit: key.ratelimitLimit!,
+ duration: key.ratelimitDuration!,
+ },
+ });
+ }
+ } while (cursor);
+ await conn.end();
+ console.info("Migration completed. Keys Changed");
+}
+
+main();