---
title: "Send Auth Email with Cloudflare"
url: "https://effect-auth.itsbroly.com/recipes/send-auth-email-with-cloudflare/"
description: "Deliver effect-auth reset, verification, OTP, magic-link, and login-approval email with Alchemy v2."
---



One transport can serve every built-in auth email:

```text
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](/quick-start/), then enable the relevant [password](/authentication/password/), [email OTP](/authentication/email-otp/), [magic link](/authentication/magic-links/), and [step-up](/authentication/step-up/) features.

## Declare the binding [#declare-the-binding]

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

```ts title="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](https://developers.cloudflare.com/email-service/configuration/send-bindings/) and [Workers API](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/). Alchemy provisions the binding; it does not verify addresses or choose delivery policy. See [Alchemy v2](/infrastructure/alchemy/) and [Alchemy](https://alchemy.run/getting-started).

## Build the runtime layers [#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`:

```ts title="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:

```ts
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 [#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.

