Skip to content

Commit ac80d6a

Browse files
feat: integrate rate limiting by default in the manager (opt-in)
1 parent b54c44d commit ac80d6a

File tree

4 files changed

+363
-66
lines changed

4 files changed

+363
-66
lines changed

README.md

Lines changed: 105 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ A TypeScript library for secure API key management with cryptographic hashing, e
1111
- **Secure by Default**: SHA-256/SHA-512 hashing with optional salt and timing-safe comparison
1212
- **Smart Key Detection**: Automatically extracts keys from `Authorization`, `x-api-key`, or custom headers
1313
- **Built-in Caching**: Optional in-memory or Redis caching for validated keys
14-
- **Rate Limiting**: Built-in rate limiting with fixed window algorithm
14+
- **Rate Limiting**: Optional automatic rate limiting on verify calls with atomic counters
1515
- **Flexible Storage**: Memory, Redis, and Drizzle ORM adapters included
1616
- **Scope-based Permissions**: Fine-grained access control
1717
- **Key Management**: Enable/disable, rotate, and soft-revoke keys with audit trails
@@ -81,6 +81,12 @@ const keys = createKeys({
8181
// Usage tracking
8282
autoTrackUsage: true, // Automatically update lastUsedAt on verify
8383

84+
// Rate limiting (opt-in, requires cache)
85+
rateLimit: {
86+
maxRequests: 100,
87+
windowMs: 60_000, // 1 minute window
88+
},
89+
8490
// Header detection
8591
headerNames: ['x-api-key', 'authorization'],
8692
extractBearer: true,
@@ -142,8 +148,13 @@ const result = await keys.verify(headers, {
142148
// Check result
143149
if (result.valid) {
144150
console.log(result.record)
151+
// If rate limiting is enabled, result.rateLimit will include rate limit info
152+
if (result.rateLimit) {
153+
console.log(`${result.rateLimit.remaining} requests remaining`)
154+
}
145155
} else {
146-
console.log(result.error) // 'Missing API key' | 'Invalid API key' | 'API key has expired' | 'API key is disabled' | 'API key has been revoked'
156+
console.log(result.error) // 'Missing API key' | 'Invalid API key' | 'API key has expired' | 'API key is disabled' | 'API key has been revoked' | 'Rate limit exceeded'
157+
console.log(result.errorCode) // 'MISSING_KEY' | 'INVALID_KEY' | 'EXPIRED' | 'DISABLED' | 'REVOKED' | 'RATE_LIMIT_EXCEEDED'
147158
}
148159
```
149160

@@ -177,98 +188,80 @@ Protect your API from abuse with built-in rate limiting. Uses the same cache inf
177188

178189
**Note:** Cache must be enabled to use rate limiting.
179190

191+
#### Automatic Rate Limiting
192+
193+
Enable rate limiting globally on all verify calls by adding the `rateLimit` config option:
194+
180195
```typescript
181196
const keys = createKeys({
182197
cache: true, // Required for rate limiting
183-
cacheTtl: 60,
184-
})
185-
186-
// Create a rate limiter
187-
const rateLimiter = keys.createRateLimiter({
188-
maxRequests: 100,
189-
windowMs: 60_000, // 1 minute window
190-
keyPrefix: 'ratelimit', // optional, defaults to 'ratelimit'
198+
rateLimit: {
199+
maxRequests: 100,
200+
windowMs: 60_000, // 1 minute window
201+
},
191202
})
192203

193-
// Check rate limit
204+
// Rate limiting happens automatically on verify()
194205
const result = await keys.verify(headers)
195-
if (!result.valid) {
196-
return { error: result.error, status: 401 }
197-
}
198206

199-
const rateLimit = await rateLimiter.check(result.record)
200-
if (!rateLimit.allowed) {
201-
return {
202-
error: 'Rate limit exceeded',
203-
status: 429,
204-
resetAt: rateLimit.resetAt,
205-
resetMs: rateLimit.resetMs,
207+
if (!result.valid) {
208+
if (result.errorCode === 'RATE_LIMIT_EXCEEDED') {
209+
return {
210+
error: 'Too many requests',
211+
status: 429,
212+
resetAt: result.rateLimit.resetAt,
213+
resetMs: result.rateLimit.resetMs,
214+
}
206215
}
216+
return { error: result.error, status: 401 }
207217
}
208218

209-
// Access rate limit info
219+
// Rate limit info is included in successful responses
210220
console.log({
211-
current: rateLimit.current, // Current request count
212-
limit: rateLimit.limit, // Max requests allowed
213-
remaining: rateLimit.remaining, // Remaining requests
214-
resetMs: rateLimit.resetMs, // Time until reset (ms)
215-
resetAt: rateLimit.resetAt, // ISO timestamp when window resets
221+
current: result.rateLimit.current, // Current request count
222+
limit: result.rateLimit.limit, // Max requests allowed
223+
remaining: result.rateLimit.remaining, // Remaining requests
224+
resetMs: result.rateLimit.resetMs, // Time until reset (ms)
225+
resetAt: result.rateLimit.resetAt, // ISO timestamp when window resets
216226
})
217227
```
218228

219-
**Dry-run checks** (check without incrementing):
220-
```typescript
221-
const rateLimit = await rateLimiter.check(record, { increment: false })
222-
```
223-
224-
**Custom identifiers** (e.g., per-owner limits instead of per-key):
225-
```typescript
226-
const rateLimit = await rateLimiter.check(record, {
227-
identifier: record.metadata.ownerId, // Rate limit by user, not by key
228-
})
229-
```
230-
231-
**Manual reset**:
232-
```typescript
233-
await rateLimiter.reset(record)
234-
```
235-
236-
**Get current count without incrementing**:
237-
```typescript
238-
const count = await rateLimiter.getCurrentCount(record)
239-
```
240-
241229
**Complete middleware example with rate limit headers**:
242230
```typescript
243231
app.use('/api/*', async (c, next) => {
244232
const result = await keys.verify(c.req.raw.headers)
245233

246234
if (!result.valid) {
235+
if (result.errorCode === 'RATE_LIMIT_EXCEEDED') {
236+
c.header('Retry-After', Math.ceil(result.rateLimit.resetMs / 1000).toString())
237+
c.header('X-RateLimit-Limit', result.rateLimit.limit.toString())
238+
c.header('X-RateLimit-Remaining', '0')
239+
c.header('X-RateLimit-Reset', result.rateLimit.resetAt)
240+
return c.json({ error: 'Too many requests' }, 429)
241+
}
247242
return c.json({ error: result.error }, 401)
248243
}
249244

250-
const rateLimit = await rateLimiter.check(result.record)
251-
252-
// Set rate limit headers (standard practice)
253-
c.header('X-RateLimit-Limit', rateLimit.limit.toString())
254-
c.header('X-RateLimit-Remaining', rateLimit.remaining.toString())
255-
c.header('X-RateLimit-Reset', rateLimit.resetAt)
256-
257-
if (!rateLimit.allowed) {
258-
c.header('Retry-After', Math.ceil(rateLimit.resetMs / 1000).toString())
259-
return c.json({
260-
error: 'Too many requests',
261-
resetAt: rateLimit.resetAt,
262-
}, 429)
263-
}
245+
// Set rate limit headers on successful requests
246+
c.header('X-RateLimit-Limit', result.rateLimit.limit.toString())
247+
c.header('X-RateLimit-Remaining', result.rateLimit.remaining.toString())
248+
c.header('X-RateLimit-Reset', result.rateLimit.resetAt)
264249

265250
c.set('apiKey', result.record)
266251
await next()
267252
})
268253
```
269254

270-
**Different limits for different endpoints**:
255+
#### Manual Rate Limiting (Advanced)
256+
257+
For custom rate limiting scenarios (e.g., different limits per endpoint), create rate limiters manually:
258+
271259
```typescript
260+
const keys = createKeys({
261+
cache: true, // Required for rate limiting
262+
})
263+
264+
// Create custom rate limiters
272265
const strictLimiter = keys.createRateLimiter({
273266
maxRequests: 10,
274267
windowMs: 60_000, // 10 requests per minute
@@ -281,17 +274,55 @@ const normalLimiter = keys.createRateLimiter({
281274

282275
// Use strict limiter for sensitive endpoints
283276
app.post('/api/sensitive', async (c) => {
284-
const rateLimit = await strictLimiter.check(c.get('apiKey'))
277+
const result = await keys.verify(c.req.raw.headers)
278+
if (!result.valid) {
279+
return c.json({ error: result.error }, 401)
280+
}
281+
282+
const rateLimit = await strictLimiter.check(result.record)
283+
if (!rateLimit.allowed) {
284+
return c.json({ error: 'Too many requests' }, 429)
285+
}
285286
// ...
286287
})
287288

288289
// Use normal limiter for regular endpoints
289290
app.get('/api/data', async (c) => {
290-
const rateLimit = await normalLimiter.check(c.get('apiKey'))
291+
const result = await keys.verify(c.req.raw.headers)
292+
if (!result.valid) {
293+
return c.json({ error: result.error }, 401)
294+
}
295+
296+
const rateLimit = await normalLimiter.check(result.record)
297+
if (!rateLimit.allowed) {
298+
return c.json({ error: 'Too many requests' }, 429)
299+
}
291300
// ...
292301
})
293302
```
294303

304+
**Dry-run checks** (check without incrementing):
305+
```typescript
306+
const rateLimit = await rateLimiter.check(record, { increment: false })
307+
```
308+
309+
**Custom identifiers** (e.g., per-owner limits instead of per-key):
310+
```typescript
311+
const rateLimit = await rateLimiter.check(record, {
312+
identifier: record.metadata.ownerId, // Rate limit by user, not by key
313+
})
314+
```
315+
316+
**Manual reset**:
317+
```typescript
318+
await rateLimiter.reset(record)
319+
```
320+
321+
**Get current count without incrementing**:
322+
```typescript
323+
const count = await rateLimiter.getCurrentCount(record)
324+
```
325+
295326
### Helper Methods
296327

297328
```typescript
@@ -501,6 +532,14 @@ interface VerifyResult {
501532
valid: boolean
502533
record?: ApiKeyRecord
503534
error?: string
535+
errorCode?: ApiKeyErrorCode
536+
rateLimit?: {
537+
current: number
538+
limit: number
539+
remaining: number
540+
resetMs: number
541+
resetAt: string
542+
}
504543
}
505544

506545
interface RateLimitConfig {

src/manager.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,4 +1105,145 @@ describe("ApiKeyManager - Additional Operations", () => {
11051105
expect(cached).toBeNull();
11061106
});
11071107
});
1108+
1109+
describe("rate limiting", () => {
1110+
it("should enforce rate limits on verify calls", async () => {
1111+
const keysWithRateLimit = createKeys({
1112+
prefix: "sk_",
1113+
cache: true,
1114+
rateLimit: {
1115+
maxRequests: 3,
1116+
windowMs: 60_000,
1117+
},
1118+
});
1119+
1120+
const { key } = await keysWithRateLimit.create({ ownerId: "user_1" });
1121+
1122+
// First 3 requests should succeed
1123+
const ALLOWED_REQUESTS = 3;
1124+
for (let i = 0; i < ALLOWED_REQUESTS; i++) {
1125+
const result = await keysWithRateLimit.verify(key);
1126+
expect(result.valid).toBe(true);
1127+
expect(result.rateLimit).toBeDefined();
1128+
expect(result.rateLimit?.limit).toBe(ALLOWED_REQUESTS);
1129+
expect(result.rateLimit?.remaining).toBe(ALLOWED_REQUESTS - (i + 1));
1130+
}
1131+
1132+
// 4th request should be rate limited
1133+
const blockedResult = await keysWithRateLimit.verify(key);
1134+
expect(blockedResult.valid).toBe(false);
1135+
expect(blockedResult.errorCode).toBe("RATE_LIMIT_EXCEEDED");
1136+
expect(blockedResult.error).toBe("Rate limit exceeded");
1137+
expect(blockedResult.rateLimit).toBeDefined();
1138+
// biome-ignore lint/style/noMagicNumbers: 4 is the blocked request count
1139+
expect(blockedResult.rateLimit?.current).toBe(4);
1140+
expect(blockedResult.rateLimit?.remaining).toBe(0);
1141+
});
1142+
1143+
it("should not include rate limit info when rate limiting is disabled", async () => {
1144+
const keysWithoutRateLimit = createKeys({
1145+
prefix: "sk_",
1146+
cache: true,
1147+
});
1148+
1149+
const { key } = await keysWithoutRateLimit.create({ ownerId: "user_1" });
1150+
const result = await keysWithoutRateLimit.verify(key);
1151+
1152+
expect(result.valid).toBe(true);
1153+
expect(result.rateLimit).toBeUndefined();
1154+
});
1155+
1156+
it("should throw error when rate limiting is configured without cache", () => {
1157+
expect(() =>
1158+
createKeys({
1159+
prefix: "sk_",
1160+
rateLimit: {
1161+
maxRequests: 100,
1162+
windowMs: 60_000,
1163+
},
1164+
})
1165+
).toThrow("Cache is required for rate limiting");
1166+
});
1167+
1168+
it("should rate limit per API key", async () => {
1169+
const keysWithRateLimit = createKeys({
1170+
prefix: "sk_",
1171+
cache: true,
1172+
rateLimit: {
1173+
maxRequests: 2,
1174+
windowMs: 60_000,
1175+
},
1176+
});
1177+
1178+
const { key: key1 } = await keysWithRateLimit.create({
1179+
ownerId: "user_1",
1180+
});
1181+
const { key: key2 } = await keysWithRateLimit.create({
1182+
ownerId: "user_2",
1183+
});
1184+
1185+
// Use key1 twice (hit limit)
1186+
await keysWithRateLimit.verify(key1);
1187+
await keysWithRateLimit.verify(key1);
1188+
1189+
// Third request for key1 should be blocked
1190+
const key1Result = await keysWithRateLimit.verify(key1);
1191+
expect(key1Result.valid).toBe(false);
1192+
expect(key1Result.errorCode).toBe("RATE_LIMIT_EXCEEDED");
1193+
1194+
// key2 should still work (separate rate limit)
1195+
const key2Result = await keysWithRateLimit.verify(key2);
1196+
expect(key2Result.valid).toBe(true);
1197+
expect(key2Result.rateLimit?.remaining).toBe(1);
1198+
});
1199+
1200+
it("should include rate limit info in successful responses", async () => {
1201+
const keysWithRateLimit = createKeys({
1202+
prefix: "sk_",
1203+
cache: true,
1204+
rateLimit: {
1205+
maxRequests: 10,
1206+
windowMs: 60_000,
1207+
},
1208+
});
1209+
1210+
const { key } = await keysWithRateLimit.create({ ownerId: "user_1" });
1211+
const result = await keysWithRateLimit.verify(key);
1212+
1213+
expect(result.valid).toBe(true);
1214+
expect(result.rateLimit).toBeDefined();
1215+
expect(result.rateLimit?.current).toBe(1);
1216+
expect(result.rateLimit?.limit).toBe(10);
1217+
// biome-ignore lint/style/noMagicNumbers: 9 is the remaining requests
1218+
expect(result.rateLimit?.remaining).toBe(9);
1219+
expect(result.rateLimit?.resetMs).toBeGreaterThan(0);
1220+
expect(result.rateLimit?.resetAt).toMatch(ISO_DATE_REGEX);
1221+
});
1222+
1223+
it("should rate limit from cache path", async () => {
1224+
const keysWithRateLimit = createKeys({
1225+
prefix: "sk_",
1226+
cache: true,
1227+
rateLimit: {
1228+
maxRequests: 2,
1229+
windowMs: 60_000,
1230+
},
1231+
});
1232+
1233+
const { key } = await keysWithRateLimit.create({ ownerId: "user_1" });
1234+
1235+
// First verify (cache miss)
1236+
const result1 = await keysWithRateLimit.verify(key);
1237+
expect(result1.valid).toBe(true);
1238+
1239+
// Second verify (cache hit)
1240+
const result2 = await keysWithRateLimit.verify(key);
1241+
expect(result2.valid).toBe(true);
1242+
1243+
// Third verify should be rate limited (cache hit)
1244+
const result3 = await keysWithRateLimit.verify(key);
1245+
expect(result3.valid).toBe(false);
1246+
expect(result3.errorCode).toBe("RATE_LIMIT_EXCEEDED");
1247+
});
1248+
});
11081249
});

0 commit comments

Comments
 (0)