# Redis rate-limit store

> A Redis-backed store gives every replica the same rate-limit counter. Requests cannot gain a separate allowance by reaching another instance.

The default `rateLimit()` middleware uses an in-process memory store. That is perfect for a single Node process but unsafe behind multiple replicas. Each instance keeps its own counter, so a client in practice gets `N * max` requests per window.

DaloyJS ships an optional **Redis-backed** store at the `@daloyjs/core/rate-limit-redis` sub-export. Counters live in Redis and are updated atomically with a small Lua script (`INCR` + `PEXPIRE`), so every replica observes the same window without a hot key shootout.

**Diagram: One client hitting two replicas, one shared counter**

Participants: Client, Replica A, Replica B, Redis

1. **Client -> Replica A** (request) - Request lands on replica A - key = daloy:rl:<key>
2. **Replica A -> Redis** (async) - Atomic INCR + PEXPIRE (Lua) - count = 120 / max 120
3. **Replica A -> Client** (response) - Allowed, at the limit - 200 OK
4. **Client -> Replica B** (request) - Next request load-balanced away - same key, different process
5. **Replica B -> Redis** (async) - INCR sees the shared count - count = 121 > 120
6. **Replica B -> Client** (response) - Rejected by the shared window - 429 + Retry-After

Every replica increments the same Redis key, so switching doors does not reset the count. With the in-memory store each replica keeps its own counter and a client can get N times the limit.

## When to use Redis (and when not to)

The Redis store is built for **long-lived multi-replica deployments**: VPS, containers, Kubernetes, Fly.io, Render, ECS, App Runner, Railway. Anywhere you run more than one Node / Bun / Deno process and need a shared counter so a client can't get `N×` the limit by load-balancing across replicas.

On **edge runtimes** (Cloudflare Workers, Fastly Compute), prefer the platform's native primitive rather than fronting Redis from every region:

- Cloudflare Workers: Durable Objects (strongly consistent per-key), or KV / D1 for relaxed consistency.
- Fastly Compute: Edge Dictionaries for static quotas, KV Store for dynamic counters.

`rateLimit()` accepts any object implementing the `RateLimitStore` contract, so each of these platforms can be wired up in a few lines using the same middleware. The Redis adapter shown below is the most common case.

## Install your Redis client

DaloyJS does not bundle a Redis client. Pick whichever is already in your stack. There are first-class adapters for the two most common options.

```bash
# pick one
pnpm add ioredis
pnpm add redis        # node-redis v4+
```

## Run a Redis to point at

The adapter needs a Redis it can reach. DaloyJS does not start one for you. For local development the quickest path is a container:

```bash
docker run --rm -p 6379:6379 redis:7-alpine
# then, in your app's environment:
export REDIS_URL=redis://127.0.0.1:6379
```

In production use a managed Redis (Upstash, ElastiCache, Memorystore, Redis Cloud) and read the URL from the environment. Keep exactly one client per process and share it, see [What it does not do](#what-it-does-not-do) below.

## Quick start (ioredis)

```ts
import IORedis from "ioredis";
import { App, rateLimit } from "@daloyjs/core";
import {
  redisRateLimitStore,
  ioredisAdapter,
} from "@daloyjs/core/rate-limit-redis";

const redis = new IORedis(process.env.REDIS_URL!);

const app = new App();
app.use(
  rateLimit({
    windowMs: 60_000,
    max: 120,
    store: redisRateLimitStore({ client: ioredisAdapter(redis) }),
    trustProxyHeaders: true,
  }),
);
```

## Quick start (node-redis v4+)

```ts
import { createClient } from "redis";
import { App, rateLimit } from "@daloyjs/core";
import {
  redisRateLimitStore,
  nodeRedisAdapter,
} from "@daloyjs/core/rate-limit-redis";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const app = new App();
app.use(
  rateLimit({
    windowMs: 60_000,
    max: 120,
    store: redisRateLimitStore({ client: nodeRedisAdapter(redis) }),
  }),
);
```

## How clients are keyed

This is the part people get wrong. By default `rateLimit()` keys on the unspoofable TCP peer (`peer:<addr>`, IPv6 grouped per `ipv6Subnet`; since 1.3.7). DaloyJS will not key off a spoofable forwarded IP unless you tell it to, so **behind a proxy every caller lands in the proxy's bucket** until you configure trust. Runtimes that expose no peer share one `global` bucket (Redis key `daloy:rl:global`).

- Per authenticated user (preferred): pass a `keyGenerator` that returns a stable id.
- Per source IP: set `trustProxyHeaders: true` or `trustedProxies` *only* when you run behind a proxy chain you control. The key is the **rightmost** `X-Forwarded-For` entry, the one your immediate proxy appended, so rotating spoofed entries on the left cannot mint fresh buckets. Behind more than one hop (CDN → LB → app) declare the chain length with `trustedHops`. When the origin itself can be reached, prefer `trustedProxies` so the peer socket is verified against a CIDR allowlist before any forwarded header is believed (see [the autoBan note](/docs/auto-ban#verify-the-peer-trustedproxies)). `X-Real-IP` is honoured only for a single declared hop, since it cannot express a longer chain. When no trustworthy forwarded identity remains, the key falls back to the unspoofable TCP peer (`peer:<addr>`) so one caller cannot 429 the world via a shared `global` bucket. Only peer-less edge platforms still share `global`.

```ts
app.use(
  rateLimit({
    windowMs: 60_000,
    max: 120,
    store: redisRateLimitStore({ client: ioredisAdapter(redis) }),
    // Per-user bucket; fall back to a single anonymous bucket otherwise.
    keyGenerator: (ctx) => (ctx.state.user as { id?: string })?.id ?? "anonymous",
  }),
);
```

> **Security:** never set bare `trustProxyHeaders: true` on a service reachable directly. A client can send any `X-Forwarded-For` it likes, mint a fresh bucket per spoofed IP, and walk straight past the limit. Use `trustedProxies` when the origin can be reached without your proxy, or key off the authenticated user instead. Behind a proxy that rewrites the header and is the only path to the origin, hop-based trust is enough.

For credential-entry routes, register `loginThrottle()` (or an IP/raw-header keyed `rateLimit()`) before authentication so failed credentials consume the budget before the body is read. Keep a per-authenticated-user limiter after auth when its key depends on `ctx.state.user`.

## Failure mode

By default the store is **fail-open**: if Redis throws (network blip, restart), the request is treated as if it were the only one in the window. That keeps your API available during a Redis outage at the cost of temporarily losing the limit.

Pass `onError` to change the behavior: return `"fail-closed"` to surface the error and reject the request, or hook the error into your structured logger:

```ts
// logger here is your app's structured logger (e.g. createLogger()).
redisRateLimitStore({
  client: ioredisAdapter(redis),
  onError: (err) => {
    logger.error({ err }, "redis rate-limit store failed");
    return process.env.NODE_ENV === "production" ? "fail-closed" : "fail-open";
  },
});
```

> **Fail-open only works if your Redis client fails fast.** A bare `new IORedis(url)` queues commands and retries while disconnected, so during an outage a request blocks for *tens of seconds* (until the client's retry budget, then the app's `requestTimeoutMs`, give up) before it ever reaches `onError`. That is neither fast-open nor fast-closed, only slow, and it will exhaust your connections under load. Construct the client to give up quickly:

```ts
// ioredis: fail fast so the store can fall back immediately
const redis = new IORedis(process.env.REDIS_URL!, {
  enableOfflineQueue: false,   // don't queue commands while disconnected
  maxRetriesPerRequest: 1,     // give up after a single retry
  connectTimeout: 500,
});

// node-redis equivalent
const redis = createClient({
  url: process.env.REDIS_URL,
  disableOfflineQueue: true,
  socket: { connectTimeout: 500, reconnectStrategy: (n) => Math.min(n * 50, 500) },
});
```

With those options a Redis outage resolves in milliseconds: fail-open allows the request immediately, fail-closed rejects it with a `500` immediately. Without them you get the same decision eventually, after a long stall on every request.

## Custom Redis clients

The store talks to Redis through a tiny contract: a single `eval()` method. Anything that can run a Lua script can be wrapped in a few lines:

```ts
import type { RedisCommands } from "@daloyjs/core/rate-limit-redis";

const myAdapter: RedisCommands = {
  eval: (script, keys, args) => myClient.runLua(script, keys, args),
};
```

## Key namespacing

Every key is prefixed with `daloy:rl:` by default. Override `prefix` per app or environment to avoid collisions on a shared Redis:

```ts
redisRateLimitStore({
  client: ioredisAdapter(redis),
  prefix: "myapp:prod:rl:",
});
```

## autoBan() store

The same entry point also exports `redisAutoBanStore()` (since 1.3.7), a shared store for [`autoBan()`](/docs/auto-ban#pluggable-store-multi-instance). It uses the same `RedisCommands` adapters, keeps each record as a Redis hash with a matching `PEXPIRE`, and implements the atomic `strike()` as one Lua script, so concurrent failures on different replicas cannot overwrite each other's strikes. Keys are prefixed with `daloy:ab:` by default. Requires Redis 4 or newer (multi-field `HSET`).

```ts
import { autoBan, rateLimit } from "@daloyjs/core";
import {
  redisAutoBanStore,
  redisRateLimitStore,
  ioredisAdapter,
} from "@daloyjs/core/rate-limit-redis";

const client = ioredisAdapter(redis);

app.use(rateLimit({ trustedHops: 1, store: redisRateLimitStore({ client }) }));
app.use(
  autoBan({
    trustedHops: 1,
    store: redisAutoBanStore({ client, prefix: "myapp:prod:ab:" }),
  }),
);
```

Store errors propagate to `autoBan()`: a failed ban check fails closed, while a failed strike is skipped and reported through `onStoreError` instead of failing the response. The strike clock is the calling replica's `Date.now()`, so keep replica clocks in sync (NTP).

## What it does not do

- It does not pool connections for you. Reuse a single client across requests. Do not create one per call.
- It does not synchronize clocks. The reset timestamp returned to clients is computed from the local time plus the Redis-reported TTL, which is good enough for `Retry-After` but not for fine-grained billing.
- It does not implement sliding windows. The semantics match the in-process store: a fixed window of `windowMs` with token-bucket-style counting.

---

Source: https://daloyjs.dev/docs/security/rate-limit-redis