Cloudflare Workers
Run effect-auth with D1, Durable Objects, and Alchemy v2.
Workers provide web-standard requests, streams, and Web Crypto. Bind durable state and capabilities explicitly; isolate memory is neither durable nor globally shared.
| Concern | Owner |
|---|---|
| Fetch entry, bindings, isolate lifetime | Cloudflare runtime |
| Services, policies, HTTP contract | Application plus effect-auth |
| Tables, migrations, recovery | D1 plus storage adapter/application |
| Resources and deployment graph | Alchemy v2 |
Provision bindings
import type { RATE_LIMITER as RateLimitObject } from "./src/workers/auth-backend";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
export const Database = Cloudflare.D1.Database("AuthDatabase", {
migrationsDir: "./migrations",
});
export const RateLimiter = Cloudflare.DurableObject<RateLimitObject>(
"RATE_LIMITER",
{ className: "RATE_LIMITER" }
);
export const AuthWorker = Cloudflare.Worker("AuthWorker", {
main: "./src/workers/auth-backend.ts",
url: false,
compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] },
env: {
DB: Database,
RATE_LIMITER: RateLimiter,
AUTH_SECRET: Config.redacted("AUTH_SECRET"),
AUTH_PUBLIC_URL: Config.string("AUTH_PUBLIC_URL"),
},
});Pin Alchemy and a reviewed compatibility date. With nodejs_compat, Cloudflare requires at least 2024-09-23 for the documented v2 behavior; prefer web APIs despite available shims. Apply D1 migrations during deployment, never request handling.
The Durable Object binding name, class name, Worker export, and Alchemy migration/resource declaration must agree.
Export one cached handler
HttpRouter.toWebHandler creates a scoped runtime with handler and dispose. Do not construct and abandon one per request. Cache by Cloudflare env identity; isolate eviction releases process resources.
import { RateLimitDurableObject } from "@effect-auth/core/CloudflareRateLimitDurableObject";
import { Context } from "effect";
import { HttpRouter } from "effect/unstable/http";
import { DurableObject } from "cloudflare:workers";
export class RATE_LIMITER extends RateLimitDurableObject(DurableObject) {}
interface Env {
readonly DB: D1Database;
readonly RATE_LIMITER: DurableObjectNamespace<RATE_LIMITER>;
readonly AUTH_SECRET: string;
readonly AUTH_PUBLIC_URL: string;
}
type WebHandler = ReturnType<typeof HttpRouter.toWebHandler>;
const handlers = new WeakMap<Env, WebHandler>();
const handlerFor = (env: Env): WebHandler => {
const cached = handlers.get(env);
if (cached !== undefined) return cached;
const created = HttpRouter.toWebHandler(makeAuthLayer(env), {
disableLogger: true,
});
handlers.set(env, created);
return created;
};
export default {
fetch: (request, env) => handlerFor(env).handler(request, Context.empty()),
} satisfies ExportedHandler<Env>;makeAuthLayer(env) typically provides:
| Binding/capability | Runtime Layer |
|---|---|
DB | Focused D1 database Layer and direct feature stores |
RATE_LIMITER | Durable Object rate-limit store and RateLimiterLive |
AUTH_SECRET | AuthSecretsFromRootLive plus WebCryptoLive() |
AUTH_EMAIL | Cloudflare mailer adapter or development outbox |
AUTH_PUBLIC_URL | Derive AuthHttpApiConfigLive and passkey policy |
Never use handler globals for sessions, counters, locks, or delivery queues.
DrizzleD1SqliteAuthStorageLayer() composes all maintained storage ports over
one DrizzleD1Database service. Provide the same database Layer to application
repositories at the composition root. Use D1SqliteAccountAuthStorageLive only
for an intentionally account-only schema; do not use memory storage for
production.
Origin and lifecycle
Prefer a private auth Worker behind a same-origin frontend service binding. Then the browser calls /auth/* with default cookies and no CORS. Derive the secure origin allowlist and passkey config from one exact canonical AUTH_PUBLIC_URL; production must not default it to loopback. Origin middleware does not emit CORS headers. If auth is cross-origin, configure credentialed CORS and cookies explicitly, return the exact allowed origin, include Vary: Origin, and keep the client origin in the explicit policy.
For Cloudflare client IP metadata, configure requestMetadata.ipSource as { _tag: "CloudflareConnectingIp" }. This assumes Cloudflare overwrites CF-Connecting-IP and the backend is reachable only through the controlled route/service binding. Do not expose the backend directly. For XForwardedFor, core selects addresses.length - trustedHops, where the current trusted peer is not an XFF entry; a short or malformed chain omits IP. Trusted-hop counts describe topology but do not authenticate the immediate peer.
Await persistence and required email-delivery responsibility before responding. Use ctx.waitUntil only for non-critical post-response work; use Queues for durable retries.
| Validate before production | Why |
|---|---|
| D1 backup, restore, and migration ledger | Authentication state is durable security data |
| Deployed D1 smoke | Fake D1/workerd do not prove platform behavior |
| Durable Object coordination | Isolate-local limits reset and race |
| CPU, memory, subrequest, bundle limits | Plan limits differ |
| Secret/state/log access | Alchemy and auth output can be sensitive |
| Compatibility-date upgrade | Runtime behavior changes over time |
Continue with Cloudflare D1, Alchemy v2, TanStack Start, and the Production Checklist.