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. Inert whileWEBJS_NO_TRUST_PROXY=1is set, which outranks it. See Behind a proxy below for the threat model.clientIpHeader: name the ONE forwarded header carrying the visitor, e.g.'cf-connecting-ip'. RequirestrustProxy: true, and replaces the chain above rather than extending it. Usually required behind a CDN, see Behind a CDN, name the header.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:
A proxy POOL fails the other way, and it is the failure you are more likely to hit, because it does not look like a failure at all. Each proxy in the pool is a separate peer, so each gets its own full allowance and your effective limit is the configured one multiplied by the pool size. The headers stay plausible throughout: every response carries a X-RateLimit-Remaining that counts down correctly for its own bucket, so the limiter reads as working while no visitor is ever refused. The tell is that a fresh connection restarts the count while requests sharing one keep-alive connection do count down. This is what shipped in the feature gallery's rate-limit demo, which is why that demo now sets trustProxy: true and names its header.
// app/api/auth/middleware.ts
import { rateLimit } from '@webjsdev/server';
export default rateLimit({ window: '1m', max: 10, trustProxy: true }); Behind a CDN, name the header
trustProxy: true alone is often NOT enough, and this is the part that costs people a debugging session. The default chain starts at the leftmost X-Forwarded-For entry, which behind Cloudflare is Cloudflare's own EGRESS address, not the visitor. Those are pinned per connection, so you get one bucket per connection: a page that pings on a button click counts down correctly and looks fixed, while every fresh connection starts a new window and nobody is ever refused. Name the header that actually carries the visitor:
export default rateLimit({
window: '1m',
max: 10,
trustProxy: true,
clientIpHeader: 'cf-connecting-ip',
}); When clientIpHeader is set it is the ONLY wire header read, falling back to the stamped socket IP and then '_anon_'. A blank value falls through rather than becoming a bucket key every visitor shares, and a comma chain is split, so a proxy that appends to the header cannot mint a bucket per hop. It requires trustProxy: true, because naming a header to trust IS the trust decision.
WebJs does not prefer CF-Connecting-IP for you, and the reason is worth stating: Cloudflare OVERWRITES that header, which makes it unforgeable behind Cloudflare and forgeable everywhere else. Preferring it globally would let a client on an nginx or bare-platform deploy send CF-Connecting-IP and outrank the X-Forwarded-For the real proxy set. Which header is trustworthy is a fact about your topology, so your app states it. Name the one YOUR edge sets and overwrites: cf-connecting-ip for Cloudflare, x-real-ip for a typical nginx setup, and the leftmost X-Forwarded-For entry (the default, no option needed) when a single trusted proxy sets that chain.
With trustProxy: true and no clientIpHeader, 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).
WEBJS_NO_TRUST_PROXY=1 OUTRANKS this option. That env var is the operator's statement that nothing trusted sits in front of the container, and it governs every forwarded-header read in the framework, so while it is set trustProxy: true is ignored and the limiter keys on the stamped socket IP (or '_anon_' when there is none), logging one warning per process. The switch can only ever subtract trust, never grant it. Setting both is a misconfiguration, and it costs you: every visitor behind the proxy shares one bucket, because the proxy is the only peer the socket ever sees. Unset the env var on a genuinely proxied deploy.
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
- Build Your Own Authentication: protect routes with auth