| 1 | --- |
| 2 | title: Secondary storage |
| 3 | description: 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 |
| 10 | import { |
| 11 | BetterAuthStorageError, |
| 12 | SecondaryStorage, |
| 13 | } from "@alchemy.run/better-auth"; |
| 14 | import { Redis } from "@upstash/redis"; |
| 15 | import * as Config from "effect/Config"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Redacted from "effect/Redacted"; |
| 19 | |
| 20 | const incrementScript = ` |
| 21 | local count = redis.call("INCR", KEYS[1]) |
| 22 | if count == 1 then |
| 23 | redis.call("EXPIRE", KEYS[1], ARGV[1]) |
| 24 | end |
| 25 | return count |
| 26 | `; |
| 27 | |
| 28 | export 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 | |
| 66 | The 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 | |
| 68 | Install `@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 | |
| 70 | Automatic 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 |
| 86 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 87 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 88 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 89 | import * as Effect from "effect/Effect"; |
| 90 | import * as Layer from "effect/Layer"; |
| 91 | import { RedisStorage } from "./storage.ts"; |
| 92 | |
| 93 | const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 94 | |
| 95 | export 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 | |
| 117 | The 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 | |
| 121 | The 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 |
| 127 | import * as Alchemy from "alchemy"; |
| 128 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 129 | import * as Effect from "effect/Effect"; |
| 130 | import AuthApi from "./worker.ts"; |
| 131 | |
| 132 | export 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 | |
| 142 | Use 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 |
| 145 | bunx alchemy deploy |
| 146 | ``` |
| 147 | |
| 148 | Preserve 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 | |
| 152 | The 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 | |
| 154 | A 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 | |
| 158 | Send 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 | |
| 160 | Write 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 | |
| 162 | Run 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. |