API rate limit per OAuth2 client/token
Throttle API consumers individually
Per-IP rate limits don't help when one client legitimately makes many requests from one IP (a backend worker). Rate limit per client_id or per access token instead.
Pattern
Request arrives → introspect token → bucket by client_id → check + increment counterImplementation with Redis
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
async function rateLimitByClient(clientId: string, limit: number, windowSec: number): Promise<boolean> {
const key = `rl:client:${clientId}:${Math.floor(Date.now() / 1000 / windowSec)}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSec);
return count <= limit;
}
// Middleware
app.use("/api", async (req, res, next) => {
const token = req.headers.authorization?.slice(7);
const info = await introspect(token!);
const allowed = await rateLimitByClient(info.client_id, 1000, 60); // 1000/min
if (!allowed) return res.status(429).json({ error: "rate_limited" });
req.user = info;
next();
});Per-tier rate limits
Different OAuth2 clients deserve different limits. Store the limit in the client's metadata:
hydra update client <id> --metadata '{"rate_limit_per_min": 5000}'Then in your handler:
const limit = info.client_metadata?.rate_limit_per_min ?? 1000; // default
const allowed = await rateLimitByClient(info.client_id, limit, 60);Per-endpoint differentiation
A search endpoint may need higher limits than a write endpoint:
const limit = endpointLimits[req.path] ?? 100;
const key = `rl:client:${clientId}:${req.path}:${windowBucket}`;Sliding window vs fixed window
The above uses fixed-window counting (resets at clock boundaries). Burst-tolerant.
For sliding window (smoother), use Redis sorted sets:
async function slidingWindowRateLimit(key: string, limit: number, windowSec: number) {
const now = Date.now();
const min = now - windowSec * 1000;
await redis.zremrangebyscore(key, 0, min);
const count = await redis.zcard(key);
if (count >= limit) return false;
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, windowSec);
return true;
}More accurate; ~2× the Redis ops.
Returning rate-limit headers
Help your callers self-throttle:
res.setHeader("X-RateLimit-Limit", limit.toString());
res.setHeader("X-RateLimit-Remaining", (limit - count).toString());
res.setHeader("X-RateLimit-Reset", (windowExpiresAt).toString());If they hit Retry-After, give them the time to wait:
res.setHeader("Retry-After", waitSec.toString());Per-user (not per-client) limits
For user-facing apps, rate-limit per-sub instead of per-client_id:
const key = `rl:user:${info.sub}:${windowBucket}`;A single user shouldn't be able to abuse your API via one client.
Where Caddy fits
Caddy's rate_limit is per-IP. Use it as a coarse-grained outer layer; per-client rate limiting in your app for fine-grained.