effect-auth

Send Auth Email with Cloudflare

Deliver effect-auth reset, verification, OTP, magic-link, and login-approval email with Alchemy v2.

One transport can serve every built-in auth email:

auth feature -> AuthMailer -> Mailer -> Cloudflare Send Email
verification -> EmailDelivery ----^

AuthMailer renders password-reset, email-verification, OTP, magic-link, and login-approval messages. EmailDeliveryFromAuthMailerLive adapts the verification-specific EmailDelivery port to that same mailer. Start with the Quick Start, then enable the relevant password, email OTP, magic link, and step-up features.

Declare the binding

Use the Alchemy version tested by this release (2.0.0-beta.61). Restrict the sender at the infrastructure boundary:

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

const from = process.env.AUTH_EMAIL_FROM;
if (!from) throw new Error("AUTH_EMAIL_FROM is required");

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

export const AuthWorker = Cloudflare.Worker("AuthWorker", {
  main: "./src/worker.ts",
  env: { AUTH_EMAIL: AuthEmail, AUTH_EMAIL_FROM: from },
});

export default Alchemy.Stack("AppAuth", {
  providers: Cloudflare.providers(),
  state: Cloudflare.state(),
}, Effect.gen(function* () {
  const worker = yield* AuthWorker;
  return { url: worker.url.as<string>() };
}));

Cloudflare requires the sender domain to be onboarded. Without a destination restriction, the binding can send only to verified destination addresses in the account; allowedDestinationAddresses narrows that set further. See Cloudflare's send-binding restrictions and Workers API. Alchemy provisions the binding; it does not verify addresses or choose delivery policy. See Alchemy v2 and Alchemy.

Build the runtime layers

Use public package exports only. The Worker receives Alchemy's resource as env.AUTH_EMAIL; CloudflareMailer adapts that binding to effect-auth's Mailer:

src/auth-email.ts
import type { SendEmail } from "@cloudflare/workers-types";
import { EmailMessage } from "cloudflare:email";
import { Layer } from "effect";
import { CloudflareMailer } from "@effect-auth/core/CloudflareEmail";
import { Email } from "@effect-auth/core/Identifiers";
import {
  AuthMailerLive,
  AuthEmailTemplatesLive,
} from "@effect-auth/core/Mailer";
import { EmailDeliveryFromAuthMailerLive } from "@effect-auth/core/EmailVerification";

declare const env: { AUTH_EMAIL: SendEmail };
const from = Email("auth@example.com");
const TransportLive = CloudflareMailer.layer<InstanceType<typeof EmailMessage>>({
  binding: env.AUTH_EMAIL,
  EmailMessage,
  from,
});

const AuthMailerRuntimeLive = AuthMailerLive({ from }).pipe(
  Layer.provide(TransportLive),
  Layer.provide(AuthEmailTemplatesLive),
);

export const AuthEmailLive = Layer.merge(
  AuthMailerRuntimeLive,
  EmailDeliveryFromAuthMailerLive.pipe(
    Layer.provide(AuthMailerRuntimeLive),
  ),
);

Cloudflare still supports this raw RFC 5322 EmailMessage API; its documentation recommends the structured builder for new provider-level integrations. CloudflareMailer is the current effect-auth adapter for the Worker binding and safely renders both text and HTML parts.

Customize AuthEmailTemplates for your product. Construct every action URL from one validated HTTPS origin, never from the request Host header:

const publicOrigin = new URL(env.AUTH_PUBLIC_URL).origin;
const actionUrl = (path: string, secret: string) => {
  const url = new URL(path, `${publicOrigin}/`);
  url.searchParams.set("secret", secret);
  return url.toString();
};

Use fixed paths for reset, verification, magic-link, and approval continuations. OTP templates display the code instead of constructing a link. Validate continuation targets against an allowlist.

Develop without sending

Local workerd delivery is not a production-equivalent mailbox. Follow the maintained tanstack-cloudflare-auth-split example: omit AUTH_EMAIL during alchemy dev, set AUTH_EMAIL_MODE="outbox", and provide an app-owned AuthMailer that writes rendered messages to a dev-only D1 outbox. Expose the viewer only in local mode, authenticate or remove it elsewhere, expire rows quickly, and clear it after tests.

Test each message type by issuing a challenge, reading the dev outbox, following the URL (or entering the OTP), and asserting single use, expiry, wrong-secret rejection, and the expected session/continuation. Unit-test templates with a recording Mailer; integration-test the production layer in a dedicated Cloudflare account and verified test destination.

In production, use HTTPS, rate-limit issuance, keep binding permissions narrow, monitor sanitized provider error codes, and decide explicitly whether failed sends are retried or durably outboxed. Never log or emit in telemetry email bodies, OTPs, challenge secrets, complete action URLs, cookies, or Alchemy state/secrets.

On this page