Skip to main content

Redis and Valkey

WIP

Sessions, 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:

EntryClient
adapters/redisAn @upstash/redis-shaped client, or fakeRedis() from @gentleduck/auth/test.
adapters/valkeyioredis 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

Storeadapters/redisadapters/valkeyOption
Sessionssession({ redis })valkeySessionImpl({ redis })stores.sessions
LimiterredisLimiter({ redis }) from limiters/redisvalkeyLimiter({ redis }) from limiters/valkeylimiter
EventsredisEvents({ redis })valkeyEvents({ cmd, sub })events
OAuth state and DPoP jtiredisDPoPNonceStore({ redis })valkeyDPoPNonceStore({ redis })nonceStore on the provider or transport
Passkey challengesredisPasskeyChallengeStore({ redis }) from providers/passkeythe 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

StoreOptionDefault
Sessionsprefix'auth'
maxTtlSec, a ceiling on every key's TTL30 days
gcLeaseSec, how long one instance holds the expiry sweep5 minutes
Limitermax attempts per window10
windowMs15 minutes
prefix'auth:rl'
Eventsprefix; each event is published on <prefix>:<event>'auth:events'
Noncesprefix'auth:dpop:jti'
Passkey challengesprefix'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 })