Rate Limiting
KickJS includes a built-in rate limiting middleware that protects your API from abuse. It uses an in-memory store by default and supports pluggable stores for distributed deployments.
The in-memory store holds at most 100,000 keys. Keys come from the client, as its IP or a header a proxy passes through, so a flood of new keys must not grow it without limit, or reset someone else's count. At the cap:
- Expired windows go first.
- Then a counter still at one hit, oldest first. A flood of single requests from made-up keys just recycles itself, and a counter a client has hit twice, such as a username with failed logins, is never pushed out. With
max: 1, one hit is already the limit, so nothing live is evicted. - Only when every counter has at least two hits is a new key refused (429), until the oldest window ends. That takes two requests per key across the whole cap within one window.
The rateLimitGuard store works the same way. For several instances, or traffic at that scale, use a shared store such as Redis.
Basic Usage
import { rateLimit } from '@forinda/kickjs'
bootstrap({
modules,
middlewares: [rateLimit({ max: 100, windowMs: 60_000 })],
})This limits each client to 100 requests per minute based on their IP address.
Options
| Option | Type | Default | Description |
|---|---|---|---|
max | number | 100 | Maximum requests per window |
windowMs | number | 60_000 | Window size in milliseconds |
message | string | 'Too Many Requests' | Response message on limit exceeded |
statusCode | number | 429 | HTTP status code on limit exceeded |
keyGenerator | (req) => string | req.ip | Function to derive the rate limit key |
headers | boolean | true | Send RateLimit-* response headers |
store | RateLimitStore | In-memory | Custom store for distributed rate limiting |
skip | (req) => boolean | — | Skip rate limiting for certain requests |
skipPaths | string[] | [] | Paths to exclude from rate limiting |
Response Headers
When headers is enabled (default), the middleware sets:
RateLimit-Limit— maximum allowed requestsRateLimit-Remaining— remaining requests in the current windowRateLimit-Reset— Unix timestamp when the window resets
Per-Route Rate Limiting
Apply different limits to specific routes with rateLimitGuard() — the route-level form of rateLimit(), which is app-wide middleware — and the @Middleware decorator:
import { rateLimitGuard } from '@forinda/kickjs'
@Controller()
class AuthController {
@Post('/login')
@Middleware(rateLimitGuard({ max: 5, windowMs: 15 * 60_000 }))
async login(ctx: RequestContext) {
// 5 attempts per 15 minutes
}
}Skip Paths
Exclude health checks or public endpoints:
rateLimit({
max: 100,
skipPaths: ['/health', '/metrics'],
})Custom Key Generator
Rate limit by API key instead of IP:
rateLimit({
keyGenerator: (req) => (req.headers['x-api-key'] as string) ?? req.ip ?? '127.0.0.1',
})Custom Store (Redis)
Implement the RateLimitStore interface for distributed deployments:
import type { RateLimitStore } from '@forinda/kickjs'
class RedisStore implements RateLimitStore {
constructor(
private redis: Redis,
private windowMs: number,
) {}
async increment(key: string) {
const hits = await this.redis.incr(`rl:${key}`)
if (hits === 1) await this.redis.pexpire(`rl:${key}`, this.windowMs)
const ttl = await this.redis.pttl(`rl:${key}`)
return { totalHits: hits, resetTime: new Date(Date.now() + ttl) }
}
async decrement(key: string) {
await this.redis.decr(`rl:${key}`)
}
async reset(key: string) {
await this.redis.del(`rl:${key}`)
}
}
rateLimit({ store: new RedisStore(redis, 60_000) })Exempting routes by flag
rateLimitGuard() accepts exemptWhen, so "this endpoint is not limited" is declared on the route rather than restated as a path here:
import { defineRouteFlag, rateLimitGuard, Middleware } from '@forinda/kickjs'
const Public = defineRouteFlag('auth.public')
@Middleware(rateLimitGuard({ max: 60, exemptWhen: 'auth.public' }))
@Controller()
export class ApiController {
@Public
@Get('/health') // never limited
health(ctx: RequestContext) {}
@Get('/search') // limited
search(ctx: RequestContext) {}
}It takes a flag name, a list (any-of), or a predicate — including one that reads a flag's value:
rateLimitGuard({ max: 60, exemptWhen: ['auth.public', 'health.probe'] })
rateLimitGuard({
max: 60,
exemptWhen: ({ flags }) => (flags.get('rate.limit') as { rpm: number })?.rpm === 0,
})The app-wide limiter reads flags too
rateLimit() runs before route matching, so there is no ctx.route for it to read. It gets the flags a different way: every mounted route registers its method, path and flags in a table at boot, and the limiter looks the incoming request up against it.
bootstrap({
modules,
middlewares: [rateLimit({ max: 60, exemptWhen: 'auth.public' })],
})That covers what skipPaths could not — a flagged route with a param is exempt by declaration:
@Public
@Get('/probe/:name') // exempt for every :name
probe(ctx: RequestContext) {}A request matching no route matches no flags, and stays limited. That is the reason to keep this middleware pre-match rather than replacing it with a guard: an abuse control has to see traffic that hits nothing, and a route-scoped guard never does.
Keep skipPaths for paths that are not routes at all — a static mount, a proxied prefix. A flag cannot describe those, because there is no handler to put it on.