Redis and Valkey
WIPSessions, the limiter, the event bus and the single-use stores on Redis or Valkey, the pieces a fleet of instances must share
Redis holds what every instance must see at once: sessions, rate-limit buckets, events, and the stores that burn a value once. Identities and credentials stay in SQL.
There are two entries for the same stores, differing only in the client they take:
| Entry | Client |
|---|---|
adapters/redis | An @upstash/redis-shaped client, or fakeRedis() from @gentleduck/auth/test. |
adapters/valkey | ioredis or iovalkey, for Redis or Valkey. |
Setup
With ioredis:
import { drizzlePgAdapter } from '@gentleduck/auth/adapters/drizzle/pg'
import { valkeyEvents, valkeySessionImpl } from '@gentleduck/auth/adapters/valkey'
import { createAuth } from '@gentleduck/auth/core'
import { valkeyLimiter } from '@gentleduck/auth/limiters/valkey'
import { passwords } from '@gentleduck/auth/providers/passwords'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL ?? '')
const sql = drizzlePgAdapter(process.env.DATABASE_URL ?? '')
const auth = createAuth({
baseUrl: 'https://app.example.com',
stores: { identities: sql.identities, credentials: sql.credentials, sessions: valkeySessionImpl({ redis }) },
limiter: valkeyLimiter({ redis }),
events: valkeyEvents({ cmd: redis, sub: new Redis(process.env.REDIS_URL ?? '') }),
providers: [passwords()],
strict: false,
})
auth.events.on('lockout', alertOps)
auth.strict({ env: 'production' })
sub must be a connection of its own: a subscribed connection can run no other command. Production hardening explains the strict order.
With a mixed bag of stores, withTransaction is not available.
What goes where
| Store | adapters/redis | adapters/valkey | Option |
|---|---|---|---|
| Sessions | session({ redis }) | valkeySessionImpl({ redis }) | stores.sessions |
| Limiter | redisLimiter({ redis }) from limiters/redis | valkeyLimiter({ redis }) from limiters/valkey | limiter |
| Events | redisEvents({ redis }) | valkeyEvents({ cmd, sub }) | events |
OAuth state and DPoP jti | redisDPoPNonceStore({ redis }) | valkeyDPoPNonceStore({ redis }) | nonceStore on the provider or transport |
| Passkey challenges | redisPasskeyChallengeStore({ redis }) from providers/passkey | the same, over valkeyAdapter(redis) | challengeStore on the passkey provider |
valkeyAdapter(client) turns an ioredis or iovalkey client into the shape adapters/redis takes, so any redis* store works with it.
strict({ env: 'production' }) refuses the in-process version of every row above, since each one lets a second instance miss what the first did: a limiter budget granted once per instance, a revocation another instance never hears, an OAuth callback or a passkey assertion that replays on a neighbour.
Options
| Store | Option | Default |
|---|---|---|
| Sessions | prefix | 'auth' |
maxTtlSec, a ceiling on every key's TTL | 30 days | |
gcLeaseSec, how long one instance holds the expiry sweep | 5 minutes | |
| Limiter | max attempts per window | 10 |
windowMs | 15 minutes | |
prefix | 'auth:rl' | |
| Events | prefix; each event is published on <prefix>:<event> | 'auth:events' |
| Nonces | prefix | 'auth:dpop:jti' |
| Passkey challenges | prefix | 'auth:passkey:challenge' |
Session updates are a compare-and-swap in Lua, so give the client eval. Without it, two concurrent writes to one session can lose the first.
Idempotency
redisIdempotency and valkeyIdempotency from @gentleduck/auth/core/idempotency replay a response for a repeated Idempotency-Key. The engine holds none; your route calls it:
import { createHash } from 'node:crypto'
import { valkeyIdempotency } from '@gentleduck/auth/core/idempotency'
const idempotency = valkeyIdempotency({ redis })
app.post('/payments', async (req, res) => {
const key = req.get(idempotency.headerName)
if (!key || key.length > 256) return res.status(400).json({ error: 'idempotency-key-required' })
const fingerprint = createHash('sha256').update(`POST /payments ${JSON.stringify(req.body)}`).digest('hex')
const response = await idempotency.handle(
key,
{},
async () => ({ status: 201, body: await charge(req.body), createdAt: new Date() }),
{ identityId: req.user?.id, fingerprint },
)
res.status(response.status).json(response.body)
})
- A missing key, or one over 256 characters, runs the executor uncached, so a route that must not repeat refuses it first.
- The executor runs once per key, and a repeat within
ttlMs(a day by default) answers the cached response. - A thrown error or a 5xx caches nothing, so the client's retry runs for real.
- A repeat arriving while the first is still running waits up to
pollTimeoutMs(5s) for it, then answers 409. - Pass
fingerprint, a hash of the method, path and body, and a key reused for a different request answers 422. - Pass
identityId, or every anonymous caller shares one bucket and the key alone authorises the replay.
Testing
fakeRedis() from @gentleduck/auth/test is an in-memory client for the adapters/redis stores:
import { session } from '@gentleduck/auth/adapters/redis'
import { fakeRedis } from '@gentleduck/auth/test'
const redis = fakeRedis()
const sessions = session({ redis })