Skip to content

Search docs

Jump between documentation pages.

Browse docs

WebSocket and login safeguards

Use wsRateLimit() on upgrades, loginThrottle() on failed authentication, and rotateSession() 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.

One bucket, two transports
AttackerloginThrottle()wsRateLimit()Shared bucket
  1. 01requestAttackerloginThrottle()Brute-force POST /login attemptseach attempt spends from groupId: auth-entry
  2. 02asyncloginThrottle()Shared bucketIncrement the same keyed counterwindowMs / max enforced
  3. 03requestAttackerwsRateLimit()Switch transports: WebSocket upgradebeforeUpgrade on /session
  4. 04asyncwsRateLimit()Shared bucketSpends from the SAME groupId bucketno fresh budget for switching transport
  5. 05noteShared bucketAttackerLimit exhausted, both paths rejectHTTP 429 / upgrade refused
Putting the same groupId on the login route and the WebSocket upgrade makes both helpers spend from one shared counter. An attacker who exhausts the HTTP budget cannot get a fresh allowance by switching to the WebSocket transport.
ts
import { App, loginThrottle, wsRateLimit } from "@daloyjs/core";

const app = new App({ env: "production" });

const authBucket = {
  windowMs: 60_000,
  max: 10,
  groupId: "auth-entry",
  keyGenerator: (ctx) => ctx.request.headers.get("x-user-key") ?? "global",
};

app.post(
  "/login",
  {
    hooks: loginThrottle(authBucket),
    responses: { 200: { description: "ok" } },
  },
  async () => ({ status: 200 as const, body: { ok: true } }),
);

app.ws("/session", {
  beforeUpgrade: wsRateLimit(authBucket),
  open(conn) {
    conn.send("ready");
  },
});

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; 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).

ts
app.post(
  "/password-reset",
  {
    hooks: loginThrottle({
      windowMs: 15 * 60_000,
      max: 5,
      groupId: "auth-entry",
      delayAfter: 2,
      delayMs: 250,
      maxDelayMs: 2_000,
    }),
    responses: { 204: { description: "accepted" } },
  },
  async () => ({ status: 204 as const }),
);

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.

ts
import { session, rotateSession } from "@daloyjs/core";

app.use(session({ secret: process.env.SESSION_SECRET! }));
app.use(rotateSession({ watch: ["userId", "roles", "tenantId"] }));

app.post(
  "/admin/promote",
  {
    responses: { 200: { description: "ok" } },
  },
  async ({ state }) => {
    state.session.set("roles", ["admin"]);
    return { status: 200 as const, body: { ok: true } };
  },
);

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.

ts
fileField({
  maxBytes: 1_000_000,
  accept: ["image/png", "image/jpeg"],
  magicBytes: true,
});

fileField({
  accept: ["application/x-daloy"],
  magicBytes: [
    { mime: "application/x-daloy", bytes: [0x44, 0x4c, 0x59] },
  ],
});

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.

ts
const app = new App({
  openapi: {
    securitySchemes: {
      webhook: httpBearerScheme({ requirePayloadAuth: true }),
    },
  },
});

app.post(
  "/webhooks/provider",
  {
    auth: { scheme: "webhook" },
    responses: { 204: { description: "accepted" } },
  },
  async () => ({ status: 204 as const }),
);

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.

ts
app.ws("/events", {
  idleTimeout: 120,
  maxPayloadLength: 64 * 1024,
  closeOnBackpressureLimit: true,
  backpressureLimit: 1 * 1024 * 1024,
  perMessageDeflate: false,
  message(conn, data) {
    conn.send(data);
  },
});