1---
2title: Secondary storage
3description: Supply a strongly consistent session and rate-limit store through an Effect layer.
4---
5
6## Implement the complete storage contract
7
8```typescript
9// storage.ts
10import {
11 BetterAuthStorageError,
12 SecondaryStorage,
13} from "@alchemy.run/better-auth";
14import { Redis } from "@upstash/redis";
15import * as Config from "effect/Config";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Redacted from "effect/Redacted";
19
20const incrementScript = `
21local count = redis.call("INCR", KEYS[1])
22if count == 1 then
23 redis.call("EXPIRE", KEYS[1], ARGV[1])
24end
25return count
26`;
27
28export const RedisStorage = Layer.effect(
29 SecondaryStorage,
30 Effect.gen(function* () {
31 const url = yield* Config.String("REDIS_REST_URL");
32 const token = yield* Config.Redacted("REDIS_REST_TOKEN");
33 const run = <A>(operation: (redis: Redis) => Promise<A>) =>
34 Effect.tryPromise({
35 try: (signal) => operation(new Redis({
36 url,
37 token: Redacted.value(token),
38 automaticDeserialization: false,
39 enableAutoPipelining: false,
40 retry: false,
41 signal,
42 })),
43 catch: (cause) => new BetterAuthStorageError({
44 message: "Secondary storage operation failed",
45 cause,
46 }),
47 });
48 const keyFor = (key: string) => `better-auth:${key}`;
49 return SecondaryStorage.of({
50 get: (key) => run((redis) => redis.get<string>(keyFor(key))),
51 set: (key, value, ttlSeconds) => run((redis) =>
52 ttlSeconds === undefined
53 ? redis.set(keyFor(key), value)
54 : redis.set(keyFor(key), value, { ex: ttlSeconds }),
55 ).pipe(Effect.asVoid),
56 delete: (key) => run((redis) => redis.del(keyFor(key))).pipe(Effect.asVoid),
57 getAndDelete: (key) => run((redis) => redis.getdel<string>(keyFor(key))),
58 increment: (key, ttlSeconds) => run((redis) =>
59 redis.eval<[number], number>(incrementScript, [keyFor(key)], [ttlSeconds]),
60 ),
61 });
62 }),
63);
64```
65
66The external backend must offer strongly consistent reads and writes through a Redis REST endpoint compatible with `@upstash/redis`, including `GETDEL` and `EVAL`. Use a dedicated database and credential for each environment; an eventually consistent read replica or Cloudflare KV does not satisfy this contract.
67
68Install `@upstash/redis` and provide the endpoint's `REDIS_REST_URL` and protected `REDIS_REST_TOKEN` at deployment. The wrapper exports the Effect service, not a built-in Redis implementation; this file supplies the adapter.
69
70Automatic JSON deserialization is disabled because Better Auth stores strings, including serialized JSON. Automatic retries are also disabled: replaying an increment or consume-once operation after an ambiguous network failure can change its meaning.
71
72## Preserve all five operations
73
74`get` returns a string or `null` only when a key is missing or expired. Connection and command failures become `BetterAuthStorageError`, never a missing value.
75
76`set` writes a value with its optional TTL in **seconds**, using one expiring command when a TTL is supplied. A separate write and expiry could leave a non-expiring credential behind if the second command failed.
77
78`delete` succeeds for an already-missing key but still reports backend failures. `getAndDelete` uses atomic `GETDEL`, so two concurrent consumers cannot both receive the credential.
79
80`increment` creates an absent counter at `1` with its initial TTL, or increments it without extending the original expiration. The Lua script executes both initialization and expiry as one server operation; rate-limit windows are fixed, not sliding.
81
82## Provide storage before constructing Better Auth
83
84```typescript
85// worker.ts
86import { BetterAuth } from "@alchemy.run/better-auth";
87import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
88import * as Cloudflare from "alchemy/Cloudflare";
89import * as Effect from "effect/Effect";
90import * as Layer from "effect/Layer";
91import { RedisStorage } from "./storage.ts";
92
93const AuthDb = Cloudflare.D1.Database("AuthDb");
94
95export default class AuthApi extends Cloudflare.Worker<AuthApi>()(
96 "AuthApi",
97 { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } },
98 Effect.gen(function* () {
99 const auth = yield* BetterAuth({
100 basePath: "/api/auth",
101 emailAndPassword: { enabled: true },
102 session: { storeSessionInDatabase: true },
103 rateLimit: {
104 enabled: true,
105 storage: "secondary-storage",
106 window: 60,
107 max: 100,
108 },
109 });
110 return { fetch: auth.fetch };
111 }).pipe(
112 Effect.provide(Layer.merge(CloudflareD1(AuthDb), RedisStorage)),
113 ),
114) {}
115```
116
117The optional service must be present when `BetterAuth` is constructed; providing it only to a later request is too late. Users and linked accounts still need D1, while sessions, rate limiting, and some verification or OAuth state use secondary storage according to the configured options.
118
119`storeSessionInDatabase: true` retains a database copy but is not an outage fallback: Better Auth still reads sessions from secondary storage. Losing or evicting keys can sign users out, and changing stores is an operational migration rather than automatic session copying.
120
121The explicit limiter also runs in development, with a 60-second window and a maximum of 100 requests before endpoint-specific limits. It protects Better Auth's routes, not unrelated application endpoints.
122
123## Run the standalone host
124
125```typescript
126// alchemy.run.ts
127import * as Alchemy from "alchemy";
128import * as Cloudflare from "alchemy/Cloudflare";
129import * as Effect from "effect/Effect";
130import AuthApi from "./worker.ts";
131
132export default Alchemy.Stack(
133 "AuthStorage",
134 { providers: Cloudflare.providers(), state: Alchemy.localState() },
135 Effect.gen(function* () {
136 const api = yield* AuthApi;
137 return { url: api.url };
138 }),
139);
140```
141
142Use Bun, matching `alchemy` and `@alchemy.run/better-auth` releases, Better Auth 1.7.5, Effect 4, and `kysely`, in addition to the Redis SDK. Configure Cloudflare credentials and the external storage inputs before deployment; this stack provisions D1 and a stable signing secret, not the Redis service.
143
144```sh
145bunx alchemy deploy
146```
147
148Preserve the local Alchemy state and use the printed URL. A local run must use a separate storage database from production because the REST store is external, not emulated by `alchemy dev`.
149
150## Keep I/O request-scoped
151
152The layer captures only configuration; each operation creates its own REST client and request with Effect's abort signal. Auto-pipelining is disabled so no pending batch can span Worker events; the wrapper runs each operation in the captured `Alchemy.RuntimeContext`.
153
154A socket-based adapter must instead acquire disposable connections lazily within the request scope and release them there. Do not retain an open connection, response, or in-flight promise across Worker events.
155
156## Verify against the actual backend
157
158Send concurrent increments to one fresh test key and verify the results are exactly `1` through the number of calls. Increment again before expiry and verify expiration remains relative to the first increment, not the last.
159
160Write one value and issue concurrent `getAndDelete` calls: exactly one should receive the value and the others should receive `null`. Also verify seconds-based expiry, idempotent deletion, and failed Effects during an outage.
161
162Run these checks against the deployed backend rather than an in-memory substitute, then exercise real sign-in and sign-out. An atomic command does not compensate for eventually consistent reads elsewhere in the service.