# 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.

OAuth2 role Client / Relying Party

Yours when you run a BFF. DaloyJS ships the building blocks.

Starts the login redirect, exchanges the code, and holds the browser session on behalf of the user. If your frontend talks to a server you own, that server is the relying party and these primitives are the right tool. It still does not issue the tokens.

`loginThrottle()` guards an authentication endpoint that you expose. That is a normal thing to have in front of a backend-for-frontend that proxies to your provider, or on a machine-to-machine credential exchange. If it is guarding a password check you wrote yourself, throttling is not the thing to fix first.

[Auth architecture: where DaloyJS fits in OAuth2 & OpenID Connect](/docs/auth/architecture) explains all three roles and the two deployment shapes we recommend.

## 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.

**Diagram: One bucket, two transports**

Participants: Attacker, loginThrottle(), wsRateLimit(), Shared bucket

1. **Attacker -> loginThrottle()** (request) - Brute-force POST /login attempts - each attempt spends from groupId: auth-entry
2. **loginThrottle() -> Shared bucket** (async) - Increment the same keyed counter - windowMs / max enforced
3. **Attacker -> wsRateLimit()** (request) - Switch transports: WebSocket upgrade - beforeUpgrade on /session
4. **wsRateLimit() -> Shared bucket** (async) - Spends from the SAME groupId bucket - no fresh budget for switching transport
5. **Shared bucket -> Attacker** (note) - Limit exhausted, both paths reject - HTTP 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](/docs/auto-ban#verify-the-peer-trustedproxies)).

```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);
  },
});
```

---

Source: https://daloyjs.dev/docs/security/websocket-login-throttle