Security@rhythmjs/security
Rate limiting & headers
Fixed-window request budgets with standard RateLimit-* headers and pluggable stores, plus helmet-style secure headers applied after every handler.
On this page
Fixed-window rate limits#
@rhythmjs/security/rate-limit counts requests per key in a fixed window: limit requests (default 100) per windowMs (default 60 000). Within the window each request increments the key's counter; once the counter passes the limit, the middleware answers 429 with a Retry-After header holding the seconds until the window resets and a JSON body of { success: false, status: 429, message } (message defaults to "Too Many Requests"). A skip(request) predicate exempts requests (health probes, allowlisted callers) before any counting happens.
import { rateLimit } from "@rhythmjs/security/rate-limit";
new RhythmRouter()
.use(rateLimit({ limit: 100, windowMs: 60_000, skip: (request) => new URL(request.url).pathname === "/health" }))
.get("/api", handler);RateLimit-* headers#
Unless headers: false, every response, allowed or rejected, carries the draft-standard trio: RateLimit-Limit, RateLimit-Remaining (never negative), and RateLimit-Reset (seconds until the current window ends). Clients can pace themselves before ever hitting a 429.
Choosing keys#
The default key is the client IP: request.ip, which you expose in your own Bun.serve fetch (Object.defineProperty(request, "ip", { get: () => server.requestIP(request)?.address })), then the literal key "global" as a last resort (one shared bucket). X-Forwarded-For is ignored unless you set trustProxy — anyone can send that header, and a limiter keyed on it hands every client an unlimited supply of fresh buckets.
Behind a reverse proxy you must set trustProxy to the number of hops you operate (true means one): the key becomes the X-Forwarded-For entry that many hops from the right — the one your own proxy appended — so spoofed client-supplied prefixes never reach the key. Without it, every client shares the proxy's request.ip bucket and a single abuser exhausts the site-wide budget for everyone. Alternatively replace the key entirely with keyOf; an authenticated user id gives fairer buckets than an IP.
rateLimit({ trustProxy: true }); // one trusted hop (nginx, a load balancer)
rateLimit({ keyOf: async (request) => (await sessionOf(request))?.userId ?? "anonymous" });Stores#
Counting goes through the RateLimitStore interface - increment(key, windowMs) returning { count, resetAt } and reset(key), so the backend is pluggable. The default memoryRateLimitStore() keeps counters in a Map and lazily sweeps expired entries at most once per window; it is per-process, which is exactly right for one instance and an undercount across replicas. For shared limits, implement the two methods over a shared store (Bun's native Bun.redis client and an INCR/PEXPIRE pair fit in a dozen lines) and pass it as store.
WebSocket upgrades#
rateLimitWs() applies the same limiter to WebSocket handshakes. It is shaped exactly like an @rhythmjs/ws middleware — (ctx, next), calling next() while the budget lasts and setting the full 429 Response (with Retry-After and the RateLimit-* headers) on ctx.response once it is spent — so it plugs into use() directly:
import { RhythmWs } from "@rhythmjs/ws";
import { rateLimitWs } from "@rhythmjs/security/rate-limit";
const ws = new RhythmWs()
.use(rateLimitWs({ limit: 10, windowMs: 60_000 }))
.route("/chat", { message(peer, message) { peer.send(String(message)); } });Secure headers#
@rhythmjs/security/secure-headers applies helmet-style response headers after your handlers run, so nothing downstream can lose them. Ten headers are on by default; contentSecurityPolicy and crossOriginEmbedderPolicy ship off and are set only when you configure them. Every option takes a string to override the value or false to drop the header entirely.
cross-origin-opener-policy#same-origincross-origin-resource-policy#same-originreferrer-policy#no-referrerstrict-transport-security#max-age=15552000; includeSubDomainsx-content-type-options#nosniffx-dns-prefetch-control#offx-download-options#noopenx-frame-options#SAMEORIGINx-permitted-cross-domain-policies#nonex-xss-protection#0(the legacy auditor, explicitly disabled)
import { secureHeaders } from "@rhythmjs/security/secure-headers";
new RhythmRouter().use(
secureHeaders({
contentSecurityPolicy: "default-src 'self'",
xFrameOptions: "DENY",
strictTransportSecurity: false, // dropped, e.g. behind a TLS-terminating proxy that sets it
}),
);