effect-auth

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.

A private auth Worker coordinates durable Cloudflare capabilities
ConcernOwner
Fetch entry, bindings, isolate lifetimeCloudflare runtime
Services, policies, HTTP contractApplication plus effect-auth
Tables, migrations, recoveryD1 plus storage adapter/application
Resources and deployment graphAlchemy v2

Provision bindings

alchemy.run.ts
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.

src/workers/auth-backend.ts
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/capabilityRuntime Layer
DBFocused D1 database Layer and direct feature stores
RATE_LIMITERDurable Object rate-limit store and RateLimiterLive
AUTH_SECRETAuthSecretsFromRootLive plus WebCryptoLive()
AUTH_EMAILCloudflare mailer adapter or development outbox
AUTH_PUBLIC_URLDerive 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 productionWhy
D1 backup, restore, and migration ledgerAuthentication state is durable security data
Deployed D1 smokeFake D1/workerd do not prove platform behavior
Durable Object coordinationIsolate-local limits reset and race
CPU, memory, subrequest, bundle limitsPlan limits differ
Secret/state/log accessAlchemy and auth output can be sensitive
Compatibility-date upgradeRuntime behavior changes over time

Continue with Cloudflare D1, Alchemy v2, TanStack Start, and the Production Checklist.

On this page