Rate Limiting
WebJs ships a fixed-window rate limiter backed by the pluggable cache store. In development it uses in-memory counters. For shared limits across multiple instances in production, switch the global cache store to Redis at app startup (one setStore() call), and the rate limiter picks it up automatically.
When to use
- Protect login/signup endpoints from brute-force attacks.
- Throttle expensive API routes (search, AI completions, file uploads).
- Enforce usage quotas on public-facing endpoints.
When NOT to use
- For page routes that are already behind auth. Use middleware auth checks instead.
- For global DDoS protection. Use a CDN or reverse proxy (Cloudflare, nginx) in front of your server.
Basic usage
Create a middleware.ts file and export the rate limiter:
// app/api/auth/middleware.ts
import { rateLimit } from '@webjsdev/server';
export default rateLimit({ window: '1m', max: 10 });
This limits the /api/auth/* routes to 10 requests per minute per IP address.
Options
window: duration string or milliseconds. Supports:'10s','1m','1h',60000. Default:'1m'.max: maximum requests per window. Default:60.key: a string prefix or a function(req) => stringthat returns a unique key per client. Default: the framework-stamped socket IP, see Behind a proxy below.trustProxy: whentrue, the default key resolution honours the leftmostX-Forwarded-Forentry, thenCF-Connecting-IP, thenX-Real-IP, before falling back to the socket IP. Default:false. See Behind a proxy below for the threat model.message: error message in the 429 response body. Default:'Too Many Requests'.store: override the cache store (e.g. a dedicated Redis instance for rate limits).
Behind a proxy
The default IP source is the TCP socket address that the framework stamps onto every inbound request via the x-webjs-remote-ip internal header. dev.js's toWebRequest strips any inbound copy of that header before adding its own, so clients cannot spoof it from the wire. Forwarded-IP headers (X-Forwarded-For, CF-Connecting-IP, X-Real-IP) are ignored. This is the correct default for any server that handles its own TCP connections directly (bare-metal, single-VM, dev mode).
When you're fronted by a reverse proxy or CDN (Cloudflare, nginx, Caddy, Railway, Fly, Render, Vercel, Heroku), the socket IP is the proxy, not the user. Every request shares the same IP and the limiter buckets everyone together. Opt in to forwarded-header parsing:
// app/api/auth/middleware.ts
import { rateLimit } from '@webjsdev/server';
export default rateLimit({ window: '1m', max: 10, trustProxy: true });
With trustProxy: true, the limiter reads the leftmost X-Forwarded-For entry, then CF-Connecting-IP, then X-Real-IP, then the stamped socket IP, then '_anon_'. Your reverse proxy MUST strip any inbound X-Forwarded-For from the wire before adding its own; otherwise trustProxy re-introduces the spoofability it exists to defend against. Cloudflare, Fly, Railway, Render, and Vercel all strip by default. Nginx and Caddy strip only if explicitly configured (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for in nginx).
Embedded adapters (running WebJs via createRequestHandler under Express / Fastify / Bun / Deno / edge runtimes) do NOT get the socket-stamping automatically, the framework's startServer path does it. The adapter MUST call stampRemoteIp(req, remoteAddress) before passing the request to webjs:
// express adapter
import { createRequestHandler, stampRemoteIp } from '@webjsdev/server';
const handler = createRequestHandler({ appDir: './app' });
app.use(async (req, res) => {
const webReq = new Request(/* ... */, { method: req.method, headers: req.headers, /* ... */ });
const safe = stampRemoteIp(webReq, req.socket.remoteAddress);
const webRes = await handler.handle(safe);
// write webRes back to res
});
Without stampRemoteIp, the adapter passes inbound headers through unmodified. A malicious client can include x-webjs-remote-ip: <anything> on the wire and clientIp(req) will trust it, defeating the limiter even with trustProxy: false.
Custom key function
Rate limit by authenticated user instead of IP:
// app/api/posts/middleware.ts
import { rateLimit } from '@webjsdev/server';
import { auth } from '#modules/auth/index.ts';
export default rateLimit({
window: '1m',
max: 30,
key: async (req) => {
const session = await auth(req);
return session?.user?.id ?? 'anon';
},
});
Response headers
Every response from a rate-limited route includes standard headers:
x-ratelimit-limit: the configured max.x-ratelimit-remaining: requests left in the current window.x-ratelimit-reset: Unix timestamp when the window resets.
When the limit is exceeded, the response is 429 Too Many Requests with retry-after header and a JSON body: { "error": "Too Many Requests" }.
Per-route vs global
Place the middleware file at the route level you want to protect:
app/middleware.ts: rate limits every route in the app.app/api/middleware.ts: rate limits all API routes.app/api/auth/middleware.ts: rate limits only auth endpoints.
Scaling with Redis
In production with multiple server instances, set REDIS_URL and call setStore(redisStore({ url: process.env.REDIS_URL })) once at app startup. The rate limiter uses whatever store is active, so switching once applies to every rateLimit() middleware in the app.
# .env REDIS_URL=redis://localhost:6379
Next steps
- Middleware: how middleware chains work
- Caching: the underlying cache store that powers rate limiting
- Authentication: protect routes with auth