WebSocket and login safeguards
UsewsRateLimit()on upgrades,loginThrottle()on failed authentication, androtateSession()after login or a privilege change.
These focused safeguards cover authentication entry points, upload boundaries, and WebSocket upgrades with first-party helpers instead of copy-pasted local policy.
1. wsRateLimit()
wsRateLimit() adapts the existing rateLimit() shared-bucket primitive to the WebSocket upgrade boundary. Put the same groupId on HTTP login routes and the WebSocket session route so an attacker cannot dodge the bucket by switching transports.
- 01requestAttackerloginThrottle()Brute-force POST /login attemptseach attempt spends from groupId: auth-entry
- 02asyncloginThrottle()Shared bucketIncrement the same keyed counterwindowMs / max enforced
- 03requestAttackerwsRateLimit()Switch transports: WebSocket upgradebeforeUpgrade on /session
- 04asyncwsRateLimit()Shared bucketSpends from the SAME groupId bucketno fresh budget for switching transport
- 05noteShared bucketAttackerLimit exhausted, both paths rejectHTTP 429 / upgrade refused
Both helpers must derive the same key for the shared counter to work. With proxy trust configured, as above, the default key is identical on both paths. Without proxy trust, rateLimit() (and so wsRateLimit()) keys every caller into one shared bucket while loginThrottle() keys per TCP peer, so the two would not line up. If you need a custom key, derive it from something the server controls, such as getConnInfo(ctx.request)?.remoteAddress or an authenticated identity.
Never key a limiter on a raw client-supplied header (x-user-key, x-client-id, an untrusted X-Forwarded-For). The client picks the value, so an attacker sends a fresh one on every attempt and gets a fresh budget each time.
2. loginThrottle()
loginThrottle() is the built-in preset for credential-entry routes. It combines a shared hard limit with a short progressive delay before the hard 429 response. By default it does not trust proxy IP headers and keys each caller on its TCP peer address, so one client cannot exhaust the budget for every other user. It falls back to a single shared bucket only on runtimes that expose no peer address. Pass a keyGenerator or opt in to trustProxyHeaders: true / trustedProxies only behind a trusted proxy. When proxy headers are trusted, the key is the rightmost X-Forwarded-For entry (the one your proxy appended), so rotating spoofed left entries cannot reset the budget. Multi-hop chains declare their length with trustedHops. Prefer trustedProxies when the origin can be reached without the proxy (see the autoBan note).
IPv6 clients are grouped by prefix (since 1.3.7). The ipv6Subnet option (default 64) masks IPv6 addresses to a /64, the block one subscriber normally holds, so an attacker cannot mint a fresh bucket per address. Different spellings of one address (::1 and 0:0::1, or 1.2.3.4 and ::ffff:1.2.3.4) collapse to one key. The option is ignored when you pass a keyGenerator.
Register loginThrottle() before your authentication hook. It then counts every rejected attempt, including guards that throw (for example a failed bearerAuth() check) rather than return a Response. Once the budget is exhausted the 429 replaces the auth error.
3. rotateSession()
rotateSession() watches session privilege fields and calls session.regenerate() after the handler when those fields change. It skips itself when the handler already regenerated the session, so explicit login flows keep their exact behavior.
4. Upload MIME and magic-byte guards
fileField() already enforced maxBytes and MIME allowlists. Add magicBytes: true to derive known signatures from accept, or pass custom signatures for private formats. The OpenAPI generator emits x-magic-bytes alongside x-accept and x-max-bytes.
5. requirePayloadAuth
OpenAPI security scheme builders accept requirePayloadAuth: true for schemes such as webhook signatures that must authenticate the request body. A route using that scheme cannot set auth.payload: false. Daloy throws at route registration. The public OpenAPI document uses x-daloy-require-payload-auth rather than leaking a non-spec field.
6. WebSocket safe defaults
app.ws() now normalizes safe runtime defaults for Node and Bun: close on excessive outbound backpressure, a 1 MiB backpressure limit, compression off by default, a non-zero idle timeout, and a 1 MiB inbound payload cap. In production under secureDefaults, perMessageDeflate: true is refused. Daloy also refuses a maxPayloadLength larger than a route body schema's declared maximum when the schema exposes one.