effect-auth

Quick Start

Deploy effect-auth to Cloudflare with Alchemy v2, D1, Send Email, and Durable Objects.

This guide builds the complete built-in auth preset on Cloudflare Workers with Alchemy v2. Alchemy owns the stack: users, credentials, sessions, and challenges are stored in D1; email is delivered through a Cloudflare Send Email binding; rate limits use a Durable Object.

The full runtime is necessary because CoreAuthHttpApiLive mounts more than password sign-in and sign-up. Its current public contract also mounts password reset, email verification, email OTP, magic link, login approval, session, and security operations. Every mounted operation must have its service, even if your first UI only uses passwords.

Build with AI

These docs are structured for coding agents as well as humans. Give an agent the docs index first; it links the concise and full documentation bundles plus the per-page Markdown index. Every documentation page is also available by replacing its trailing / with .md, for example /quick-start.md. Prefer those Markdown sources over scraping rendered HTML, and have the agent verify imports and examples against the installed effect-auth version.

Paste this prompt into an agent running in your project:

Add effect-auth to this project, or review the existing integration if it is already installed.

Before writing code:
1. Inspect the repository, runtime, framework, database, existing user model, and package versions.
2. Read https://effect-auth.itsbroly.com/llms.txt and the relevant linked Markdown pages. Do not invent APIs or use internal imports.
3. Ask me which authentication features I need, including password, email OTP, magic links, passkeys, TOTP, recovery codes, MFA, step-up, OAuth, sessions, and account recovery.
4. Propose the appropriate mix of Presets, HTTP Operations, and Primitives on an endpoint-by-endpoint basis.
5. Propose a concrete file/module layout that follows this repository's conventions, explain the important tradeoffs, and ask me to approve or adjust it.

After I answer, implement the integration end to end: dependencies, migrations, configuration and secrets, storage, server layers and routes, browser client/UI flow where applicable, security controls, and tests. Preserve existing application architecture, use public effect-auth exports only, handle continuation states explicitly, and run the repository's checks. Ask focused follow-up questions whenever a security or product decision cannot be inferred safely.

The agent should use /llms-small.txt for quick orientation, /llms-full.txt when it needs broad context, and /llms-pages.txt to fetch only the feature pages relevant to your answers.

Prerequisites

  • Bun 1.3 or newer
  • A Cloudflare account and an API token available as CLOUDFLARE_API_TOKEN
  • A Cloudflare Email Routing destination and a verified sender address for Send Email

Start an empty Worker project:

bun init -y
bun add @effect-auth/core effect effect-qb alchemy@2.0.0-beta.61
bun add -d @cloudflare/workers-types

Effect packages are released together. Keep effect, effect-qb, and effect-auth on compatible release lines.

Add the Alchemy commands to package.json:

package.json
{
  "type": "module",
  "scripts": {
    "dev": "alchemy dev",
    "deploy": "alchemy deploy",
    "destroy": "alchemy destroy"
  }
}

Define the infrastructure

Alchemy creates the D1 database, applies the SQL files in migrations, registers the rate-limiter Durable Object, configures Send Email, and deploys the Worker. Create the stack at the project root:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";

import type { RATE_LIMITER as RateLimitDurableObject } from "./src/worker";

const authSecret = process.env.AUTH_SECRET;
const authEmailFrom = process.env.AUTH_EMAIL_FROM;

if (!authSecret) throw new Error("AUTH_SECRET is required");
if (!authEmailFrom) throw new Error("AUTH_EMAIL_FROM is required");

export const Database = Cloudflare.D1.Database("AuthDatabase", {
  migrationsDir: "./migrations",
});

export const RateLimiter = Cloudflare.DurableObject<RateLimitDurableObject>(
  "RATE_LIMITER",
  {
    className: "RATE_LIMITER",
  }
);

export const AuthEmail = Cloudflare.Email.SendEmail("AUTH_EMAIL", {
  allowedSenderAddresses: [authEmailFrom],
});

export const AuthWorker = Cloudflare.Worker("AuthWorker", {
  main: "./src/worker.ts",
  compatibility: {
    flags: ["nodejs_compat"],
  },
  env: {
    DB: Database,
    RATE_LIMITER: RateLimiter,
    AUTH_EMAIL: AuthEmail,
    AUTH_SECRET: authSecret,
    AUTH_ALLOWED_ORIGINS:
      process.env.AUTH_ALLOWED_ORIGINS ?? "http://localhost:5173",
    AUTH_COOKIE_SECURE: process.env.AUTH_COOKIE_SECURE ?? "false",
    AUTH_EMAIL_FROM: authEmailFrom,
    AUTH_PUBLIC_URL: process.env.AUTH_PUBLIC_URL ?? "http://localhost:5173",
  },
});

export default Alchemy.Stack(
  "EffectAuthQuickStart",
  {
    providers: Cloudflare.providers(),
    state: Cloudflare.state(),
  },
  Effect.gen(function* () {
    const database = yield* Database;
    const worker = yield* AuthWorker;

    return {
      databaseId: database.databaseId,
      workerUrl: worker.url.as<string>(),
    };
  })
);

AUTH_PUBLIC_URL is the browser application's public origin and constructs reset and magic-link URLs. AUTH_ALLOWED_ORIGINS is a comma-separated allowlist. Set AUTH_EMAIL_FROM to a sender permitted by Cloudflare Email Routing. In production, use HTTPS and set AUTH_COOKIE_SECURE=true.

Alchemy reads these values from the environment in which it runs. For local development, export them in your shell or use your usual ignored environment-file loader before running Alchemy:

export AUTH_SECRET="$(openssl rand -base64 32)"
export AUTH_EMAIL_FROM="auth@your-verified-domain.example"

For production, provide AUTH_SECRET through your CI or deployment platform's protected environment variables and do not commit it. This stack passes the value as a Worker binding; it does not declare a separate secret resource. Review access to deployment logs and Alchemy state accordingly, and use a distinct random value for each environment.

Apply auth migrations

The supported migration API is authStorageMigrations; raw SQL package files are not importable package subpaths. Generate every current migration because the full preset exposes all of its feature groups:

scripts/write-auth-migrations.ts
import { mkdir } from "node:fs/promises";
import { authStorageMigrations } from "@effect-auth/core/StorageMigrations";

await mkdir("migrations", { recursive: true });

for (const migration of authStorageMigrations) {
  await Bun.write(`migrations/${migration.id}.sql`, migration.sql);
}
bun scripts/write-auth-migrations.ts

The database resource consumes migrationsDir, so alchemy dev and alchemy deploy apply the generated migrations as part of provisioning. Run the generator again when upgrading effect-auth, review newly generated SQL, and deploy the new migrations with the upgraded Worker. See Storage for schema details.

Build the Worker

Create src/worker.ts. This is the maintained full-preset composition reduced to one file:

src/worker.ts
import { EmailMessage } from "cloudflare:email";
import { DurableObject } from "cloudflare:workers";
import {
  AuthDomainConfigLive,
  AuthSecretsFromRootLive,
} from "@effect-auth/core/AuthConfig";
import { AuthFlowStateLive } from "@effect-auth/core/AuthFlow";
import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { CloudflareMailer } from "@effect-auth/core/CloudflareEmail";
import {
  RateLimitDurableObject,
  RateLimitStoreDurableObject,
} from "@effect-auth/core/CloudflareRateLimitDurableObject";
import type { CloudflareRateLimitDurableObjectNamespace } from "@effect-auth/core/CloudflareRateLimitDurableObject";
import { WebCryptoLive } from "@effect-auth/core/Crypto";
import { D1EffectQbSqliteAuthStorageLive } from "@effect-auth/core/EffectQbSqliteStorage";
import { EmailOtpDefaultLive } from "@effect-auth/core/EmailOtp";
import {
  EmailDeliveryFromAuthMailerLive,
  EmailVerificationDefaultLive,
} from "@effect-auth/core/EmailVerification";
import {
  AuthHttpApiConfigLive,
  CoreAuthHttpApiLive,
} from "@effect-auth/core/HttpApi";
import { Email } from "@effect-auth/core/Identifiers";
import { LoginApprovalLive } from "@effect-auth/core/LoginApproval";
import { MagicLinkLoginLive } from "@effect-auth/core/MagicLink";
import {
  AuthEmailTemplatesLive,
  AuthMailerLive,
} from "@effect-auth/core/Mailer";
import {
  PasswordDefaultLive,
  PasswordResetDefaultLive,
} from "@effect-auth/core/Password";
import { RateLimiterLive } from "@effect-auth/core/RateLimiter";
import { Layer, Redacted } from "effect";
import { HttpRouter, HttpServer } from "effect/unstable/http";
import { RateLimiter as PersistenceRateLimiter } from "effect/unstable/persistence";

export class RATE_LIMITER extends RateLimitDurableObject(DurableObject) {}

interface Env {
  readonly DB: D1Database;
  readonly RATE_LIMITER: CloudflareRateLimitDurableObjectNamespace;
  readonly AUTH_EMAIL: SendEmail;
  readonly AUTH_SECRET: string;
  readonly AUTH_ALLOWED_ORIGINS: string;
  readonly AUTH_COOKIE_SECURE: string;
  readonly AUTH_EMAIL_FROM: string;
  readonly AUTH_PUBLIC_URL: string;
}

const linkUrl = (
  path: string,
  publicUrl: string,
  input: {
    readonly challengeId: string;
    readonly secret: Redacted.Redacted<string>;
  }
) => {
  const url = new URL(path, publicUrl);
  url.searchParams.set("challengeId", input.challengeId);
  url.searchParams.set("secret", Redacted.value(input.secret));
  return url.toString();
};

const makeAuthLayer = (env: Env) => {
  const AuthCryptoLive = WebCryptoLive();
  const authEmailFrom = Email(env.AUTH_EMAIL_FROM);
  const MailerLive = CloudflareMailer.layer<InstanceType<typeof EmailMessage>>({
    binding: env.AUTH_EMAIL,
    EmailMessage,
    from: authEmailFrom,
  });
  const AuthMailerRuntimeLive = AuthMailerLive({ from: authEmailFrom }).pipe(
    Layer.provide(MailerLive),
    Layer.provide(AuthEmailTemplatesLive)
  );
  const AuthEmailRuntimeLive = Layer.merge(
    AuthMailerRuntimeLive,
    EmailDeliveryFromAuthMailerLive.pipe(Layer.provide(AuthMailerRuntimeLive))
  );

  const AppAuthRuntimeLive = Layer.mergeAll(
    AuthSecretsFromRootLive({ root: Redacted.make(env.AUTH_SECRET) }).pipe(
      Layer.provideMerge(AuthCryptoLive)
    ),
    AuthDomainConfigLive({
      sessionCookie: {
        name:
          env.AUTH_COOKIE_SECURE === "true"
            ? "__Host-session"
            : "effect-auth-session",
        secure: env.AUTH_COOKIE_SECURE === "true",
      },
    }),
    D1EffectQbSqliteAuthStorageLive(env.DB),
    AuthEmailRuntimeLive
  );

  const AppAuthFeaturesLive = Layer.mergeAll(
    PasswordDefaultLive(),
    PasswordResetDefaultLive({
      makeUrl: (input) =>
        linkUrl("/reset-password", env.AUTH_PUBLIC_URL, input),
    }),
    EmailOtpDefaultLive(),
    MagicLinkLoginLive({
      makeUrl: (input) => linkUrl("/magic-link", env.AUTH_PUBLIC_URL, input),
    }),
    EmailVerificationDefaultLive(),
    AuthFlowStateLive(),
    LoginApprovalLive()
  );
  const AppAuthServicesLive = AppAuthFeaturesLive.pipe(
    Layer.provideMerge(AuthKernelLive),
    Layer.provideMerge(AppAuthRuntimeLive)
  );
  const AppAuthRateLimitLive = RateLimiterLive.pipe(
    Layer.provide(PersistenceRateLimiter.layer),
    Layer.provide(
      RateLimitStoreDurableObject.layer({ namespace: env.RATE_LIMITER })
    )
  );

  return CoreAuthHttpApiLive.pipe(
    Layer.provide(AuthRateLimitStandardLive()),
    Layer.provide(Layer.mergeAll(AppAuthServicesLive, AppAuthRateLimitLive)),
    Layer.provide(
      AuthHttpApiConfigLive({
        requestMetadata: { trustProxyHeaders: true },
        originCheck: { allowedOrigins: env.AUTH_ALLOWED_ORIGINS },
      })
    ),
    Layer.provide(HttpServer.layerServices)
  );
};

export default {
  fetch(request: Request, env: Env): Promise<Response> {
    const webHandler = HttpRouter.toWebHandler(makeAuthLayer(env), {
      disableLogger: true,
    });

    return webHandler.handler(request);
  },
} satisfies ExportedHandler<Env>;

The handler shape matters: current Effect returns an object from HttpRouter.toWebHandler, so invoke webHandler.handler(request), not webHandler(request).

The layer graph provides every static requirement of CoreAuthHttpApiLive:

  • PasswordDefaultLive() supplies password sign-up, sign-in, and credential management; PasswordResetDefaultLive supplies the mounted reset operations.
  • EmailVerificationDefaultLive, EmailOtpDefaultLive, and MagicLinkLoginLive supply their mounted API groups.
  • AuthFlowStateLive() and LoginApprovalLive() satisfy login-approval operations and continuation state.
  • AuthKernelLive supplies AuthFlow, sessions, session cookies, challenges, privacy, and HTTP transport. AuthFlow remains the owner of session creation.
  • AuthMailerRuntimeLive and EmailDeliveryFromAuthMailerLive adapt Cloudflare Send Email to both mail interfaces used by those features.
  • AuthRateLimitStandardLive() uses the Durable Object-backed RateLimiterLive; counters therefore survive isolate replacement.
  • AuthSecretsFromRootLive, WebCryptoLive(), AuthDomainConfigLive, and D1EffectQbSqliteAuthStorageLive provide secrets, crypto, cookie/domain policy, and storage.

Run the Worker:

bun run dev

Deploy or remove the complete stack with bun run deploy and bun run destroy. Alchemy uses the Cloudflare provider and state configured by Alchemy.Stack, so the database and bindings do not need separate create or apply commands.

Cloudflare Send Email has account, destination, and sender restrictions. Password sign-up and sign-in work locally without sending mail, but exercise reset, OTP, verification, and magic-link delivery in a Cloudflare environment configured for Email Routing. Do not replace the mailer with a no-op in production.

Create the browser client

Prefer serving or proxying /auth/* on the same origin as the browser application. Then cookies and origin checks require no cross-origin workaround:

src/auth-client.ts
import { createAuthClient } from "@effect-auth/core/Client";

export const auth = createAuthClient({
  requestInit: { credentials: "include" },
});

If the Worker has a separate origin, set baseUrl to that origin, keep it in AUTH_ALLOWED_ORIGINS, and configure CORS at your application/Worker boundary. Origin allowlisting is CSRF protection; it does not itself add browser CORS response headers.

Sign up and sign in

src/password-actions.ts
import { auth } from "./auth-client";

export const signUp = async (email: string, password: string) =>
  handleAuthResult(
    await auth.password.signUp({
      identity: { scope: { type: "global" }, kind: "email", value: email },
      password,
    })
  );

export const signIn = async (email: string, password: string) =>
  handleAuthResult(
    await auth.password.signIn({
      identity: { scope: { type: "global" }, kind: "email", value: email },
      password,
    })
  );

const handleAuthResult = <T extends { readonly type: string }>(result: T) => {
  switch (result.type) {
    case "authenticated":
      // The response committed the session cookie.
      return result;
    case "requires_mfa":
      // Continue with flowId and one of the offered factors.
      return result;
    case "requires_email_verification":
      // Continue through the email-verification API.
      return result;
    case "requires_login_approval":
      // Poll/review and finalize the approval flow.
      return result;
    case "requires_passkey_enrollment":
      // Enroll a passkey before completing authentication.
      return result;
    default:
      throw new Error(`Unhandled auth result: ${result.type}`);
  }
};

Continuations are successful protocol states, not exceptions. This default configuration normally authenticates a password sign-up/sign-in immediately, but the UI must preserve continuation data when policies are enabled. See Password authentication.

The client also exposes reset:

await auth.password.reset.start({ email });
await auth.password.reset.verify({
  challengeId,
  secret,
  password: newPassword,
});

The reset page at ${AUTH_PUBLIC_URL}/reset-password must read challengeId and secret from its URL and submit them to verify. Never log reset or magic-link URLs, query strings, or secrets.

Production caveats

  • Benchmark and enforce an application-owned password policy for sign-up, reset, set, and change.
  • Keep AUTH_SECRET in a protected deployment environment, restrict access to Alchemy state and logs, use HTTPS, and set secure cookies.
  • Configure and test Cloudflare Email Routing before exposing email-backed routes.
  • The Worker constructs a handler per request for a simple, binding-safe example. A framework integration with stable bindings can cache it as the maintained examples do.
  • CoreAuthHttpApiLive is the full preset. If you want password-only routes, compose a narrower custom HttpApi from public endpoint contracts and HTTP operations rather than providing fake implementations for unused full-preset services. See Custom Auth API.
  • Review the Production Checklist.

On this page