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:
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.63). Restrict the sender at the infrastructure boundary:
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");
const publicUrl = process.env.AUTH_PUBLIC_URL;
if (!publicUrl) throw new Error("AUTH_PUBLIC_URL 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,
AUTH_PUBLIC_URL: publicUrl,
},
});
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. Alchemy turns the resource descriptor into the runtime-native SendEmail binding at env.AUTH_EMAIL; CloudflareMailer adapts it to effect-auth's Mailer:
import type { SendEmail } from "@cloudflare/workers-types";
import { EmailMessage } from "cloudflare:email";
import { Layer, Redacted } 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 { EmailDeliveryFromAuthMailerLayer } from "@effect-auth/core/EmailVerification";
declare const env: {
AUTH_EMAIL: SendEmail;
AUTH_EMAIL_FROM: string;
AUTH_PUBLIC_URL: string;
};
const from = Email(env.AUTH_EMAIL_FROM);
const verificationUrl = ({ challengeId, secret }) => {
const url = new URL("/verify-email", env.AUTH_PUBLIC_URL);
url.searchParams.set("challengeId", challengeId);
if (secret !== undefined)
url.searchParams.set("secret", Redacted.value(secret));
return url.toString();
};
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,
EmailDeliveryFromAuthMailerLayer({
makeUrl: verificationUrl,
}).pipe(Layer.provide(AuthMailerRuntimeLive))
);Cloudflare still supports this raw RFC 5322 EmailMessage API. Choose the adapter that matches the Worker style:
| Worker style | Adapter | Cloudflare API |
|---|---|---|
Conventional async Worker + env | CloudflareMailer | Raw RFC 5322 EmailMessage |
| Effect-native Alchemy Worker | AlchemyCloudflareMailer | Structured builder |
The structured builder is preferred for new Alchemy-native integrations; the raw adapter safely renders text and HTML for conventional Workers.
Use makeDefaultAuthEmailTemplates({ PasswordReset: ... }) to override individual message kinds while retaining defaults for the rest. Delivery adapters accept optional makeUrl callbacks for email verification and login approval, so URL construction does not need to live inside templates. 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
Alchemy 2.0.0-beta.63 remote-binds send_email during alchemy dev, which may deliver real mail. Follow Develop with an Email Inbox: omit AUTH_EMAIL locally, set AUTH_EMAIL_MODE="outbox", and provide AuthMailerFromDevEmailStoreLive. Use memory for one process or D1 when auth and website Workers must share the inbox. Expose the viewer only locally and clear expired rows 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.