Email and password
Start with an existing Alchemy project and a configured Cloudflare profile. This standalone walkthrough uses a Worker, D1, and Resend; every local module and browser file is introduced here.
Create each named file when it first appears, append snippets only where instructed, and apply the one diff to the already-introduced src/auth.ts. Complete all steps before starting the application; no tutorial files or other provider pages are required.
Add the authentication dependencies
Section titled “Add the authentication dependencies”bun add @alchemy.run/better-auth better-auth@1.7.5 kyselyKeep @alchemy.run/better-auth aligned with your existing Alchemy release.
Configure the mail sender
Section titled “Configure the mail sender”Create .env.local:
RESEND_API_KEY=replace-with-your-sending-keyAUTH_EMAIL_FROM="Auth <auth@your-verified-domain.example>"In Resend, verify a domain through DNS, create a sending key restricted to that domain, and replace both values with real credentials. Use a verified sender rather than a restricted sandbox sender so verification and recovery reach real inboxes.
Keep environment files private
Section titled “Keep environment files private”Append these entries to your existing .gitignore if they are not already covered:
.env.local.env.productionUse separate production credentials, never browser-prefixed variables such as VITE_RESEND_API_KEY. Keep keys, passwords, mail bodies, and token-bearing URLs out of source control and logs.
Read mail configuration with Config
Section titled “Read mail configuration with Config”Create src/mail.ts:
import * as Config from "effect/Config";import * as Effect from "effect/Effect";import * as HttpClient from "effect/unstable/http/HttpClient";import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
const mailConfig = Effect.gen(function* () { const from = yield* Config.String("AUTH_EMAIL_FROM"); const apiKey = yield* Config.Redacted("RESEND_API_KEY"); const http = yield* HttpClient.HttpClient; return { from, apiKey, http };});Alchemy resolves this server configuration when constructing the Auth service. The host will supply an HTTP client, and the API key stays redacted until the request transport uses it.
Send mail through Resend
Section titled “Send mail through Resend”Append to src/mail.ts:
export const makeSendMail = mailConfig.pipe( Effect.map(({ from, apiKey, http }) => (to: string, subject: string, text: string) => http.execute( HttpClientRequest.post("https://api.resend.com/emails").pipe( HttpClientRequest.bearerToken(apiKey), HttpClientRequest.bodyJsonUnsafe({ from, to: [to], subject, text }), ), ).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => response.text), Effect.asVoid, Effect.timeout("10 seconds"), Effect.mapError(() => new Error("Email delivery failed")), Effect.scoped, Effect.runPromise, ), ),);Each call sends one bounded request and rejects on a non-success response, without retaining an in-flight request between Worker events. Effect.runPromise bridges the mail operation to Better Auth’s Promise-based hooks, while errors avoid exposing provider responses or credentials.
Construct verified email authentication
Section titled “Construct verified email authentication”Create src/auth.ts:
import { BetterAuth } from "@alchemy.run/better-auth";import * as Config from "effect/Config";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import { makeSendMail } from "./mail.ts";
const makeAuth = Effect.gen(function* () { const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); const sendMail = yield* makeSendMail; return yield* BetterAuth({ basePath: "/api/auth", baseURL: Option.getOrUndefined(baseURL), emailAndPassword: { enabled: true, requireEmailVerification: true }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: false, sendVerificationEmail: ({ user, url }) => sendMail(user.email, "Verify your email", `Verify your email: ${url}`), }, });});Options live directly in BetterAuth, with verification required before sign-in and generated links sent unchanged as plain text. Leave AUTH_BASE_URL unset locally to derive the origin from the request; the production override comes later.
Enable password recovery
Section titled “Enable password recovery”Apply this diff to src/auth.ts, replacing its emailAndPassword property:
emailAndPassword: { enabled: true, requireEmailVerification: true },emailAndPassword: { enabled: true, requireEmailVerification: true, revokeSessionsOnPasswordReset: true, sendResetPassword: ({ user, url }) => sendMail(user.email, "Reset your password", `Reset your password: ${url}`),},Recovery sends Better Auth’s generated reset link and revokes existing sessions after a successful password reset. Keep server-side password validation and rate limiting enabled; see upstream email/password policy for additional controls.
Expose Auth as an Effect service
Section titled “Expose Auth as an Effect service”Append to src/auth.ts:
export class Auth extends Context.Service< Auth, Effect.Success<typeof makeAuth>>()("app/Auth") { static readonly layer = Layer.effect(Auth, makeAuth);}The service retains the inferred Better Auth API while leaving its database and HTTP dependencies for the host to supply. Browser code will not import this module.
Declare the database
Section titled “Declare the database”Create src/database.ts:
import * as Cloudflare from "alchemy/Cloudflare";
export const AuthDb = Cloudflare.D1.Database("AuthDb");This declares the D1 resource without embedding a database client in the Auth options. The Worker will connect it through CloudflareD1.
Forward authentication requests
Section titled “Forward authentication requests”Create src/handler.ts:
import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Auth } from "./auth.ts";
export const handlers = Effect.gen(function* () { const auth = yield* Auth; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const path = request.url.split("?")[0]; if (path === "/api/auth" || path.startsWith("/api/auth/")) { return yield* auth.fetch; } return HttpServerResponse.text("Not found", { status: 404 }); }), };});Pass auth routes through unchanged so Better Auth owns verification, reset endpoints, and session cookies. Its built-in /api/auth/error page remains available; there is no custom callback handler or authError query convention.
Supply the Worker dependencies
Section titled “Supply the Worker dependencies”Create src/worker.ts:
import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";import { Auth } from "./auth.ts";import { AuthDb } from "./database.ts";import { handlers } from "./handler.ts";
export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()( "AuthWorker", { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] }, assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, }, handlers.pipe(Effect.provide(Auth.layer.pipe( Layer.provide(CloudflareD1(AuthDb)), Layer.provide(FetchHttpClient.layer), ))),) {}The host supplies D1 and the HTTP transport while serving the browser assets alongside /api/auth. Alchemy automatically provisions a stable signing secret and runs the supported D1 migrations, including against the local D1 simulator during development—no manual secret or migration command is needed.
Define the stack
Section titled “Define the stack”Use this alchemy.run.ts for the walkthrough, or add AuthWorker to your existing stack while retaining its state backend:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import AuthWorker from "./src/worker.ts";
export default Alchemy.Stack( "EmailAuth", { providers: Cloudflare.providers(), state: Alchemy.localState() }, Effect.gen(function* () { const worker = yield* AuthWorker; return { url: worker.url }; }),);Preserve the stack’s state between runs so resource identities and the signing secret remain stable. If using this local state backend, protect and retain its state directory rather than treating it as disposable output.
Create the browser form
Section titled “Create the browser form”Create public/index.html:
<!doctype html><html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="referrer" content="no-referrer"> <title>Email sign-in</title> </head> <body> <h1>Email sign-in</h1> <form id="credentials"> <p><label>Name <input id="name" autocomplete="name"></label></p> <p><label>Email <input id="email" type="email" autocomplete="email"></label></p> <p><label>Password <input id="password" type="password" minlength="8" maxlength="128" autocomplete="off"></label></p> </form> <p id="status" role="status"></p> <script type="module" src="/client.js"></script> </body></html>The browser module will add action buttons; registration uses all three fields, sign-in uses email and password, and recovery uses the password field for the replacement password. These inputs mirror the default password length range, but the server remains authoritative.
Protect pages containing recovery links
Section titled “Protect pages containing recovery links”Create public/_headers:
/* Referrer-Policy: no-referrer Cache-Control: no-storeCloudflare applies these headers to static assets, not Worker-generated responses. Keep this page free of third-party analytics and redact token-bearing URLs in access logs, including requests handled by the Worker.
Connect the browser client
Section titled “Connect the browser client”Create src/client.ts:
import { createAuthClient } from "better-auth/client";
const authClient = createAuthClient();const form = document.querySelector<HTMLFormElement>("#credentials")!;const nameInput = document.querySelector<HTMLInputElement>("#name")!;const emailInput = document.querySelector<HTMLInputElement>("#email")!;const passwordInput = document.querySelector<HTMLInputElement>("#password")!;const status = document.querySelector<HTMLParagraphElement>("#status")!;form.addEventListener("submit", (event) => event.preventDefault());createAuthClient() uses this page’s origin and /api/auth, with no server imports or credentials. Every remaining browser snippet is appended to this same file in page order.
Display action results safely
Section titled “Display action results safely”Append to src/client.ts:
async function run(action: () => Promise<string>) { const buttons = document.querySelectorAll<HTMLButtonElement>("button"); buttons.forEach((element) => { element.disabled = true; }); try { status.textContent = await action(); } catch { status.textContent = "Could not reach the server. Try again."; } finally { passwordInput.value = ""; buttons.forEach((element) => { element.disabled = false; }); }}Render messages as text, clear the password after each action, and prevent overlapping button submissions. Never store passwords in browser storage.
Add a reusable action button
Section titled “Add a reusable action button”Append to src/client.ts:
function button(label: string, action: () => Promise<string>) { const element = document.createElement("button"); element.type = "button"; element.textContent = label; element.addEventListener("click", () => void run(action)); document.body.append(element);}Each button below runs through the same error display and password cleanup. The HTML page needs no inline event handlers.
Read the current session
Section titled “Read the current session”Append to src/client.ts:
async function readAccount(): Promise<string> { const { data, error } = await authClient.getSession(); if (error) return "Could not check your session. Try again."; return data ? `Signed in as ${data.user.name}.` : "Verify your email, then sign in.";}Read the actual session rather than assuming a successful request authenticated the user. This is browser feedback, not authorization: private server endpoints must independently check the session and application permissions.
Register an account
Section titled “Register an account”Append to src/client.ts:
button("Register", async () => { const { error } = await authClient.signUp.email({ name: nameInput.value, email: emailInput.value, password: passwordInput.value, callbackURL: "/", }); return error ? "Could not register. Check your details and try again." : "Check your inbox to verify your email, then sign in.";});Registration sends the verification message through Resend. The link passes through Better Auth’s verification endpoint and returns to /; users must then sign in because automatic sign-in after verification is disabled.
Sign in with verified credentials
Section titled “Sign in with verified credentials”Append to src/client.ts:
button("Sign in", async () => { const { data, error } = await authClient.signIn.email({ email: emailInput.value, password: passwordInput.value, }); if (error?.code === "EMAIL_NOT_VERIFIED") return "Verify your email first."; if (error) return "Could not sign in. Check your email and password."; if (!data?.user) return "Sign-in did not finish. Try again."; return readAccount();});The server refuses unverified sign-ins and issues the session cookie after successful authentication. Delivery failures are not a reason to disable verification.
Request another verification message
Section titled “Request another verification message”Append to src/client.ts:
let nextVerificationRequest = 0;button("Resend verification", async () => { if (Date.now() < nextVerificationRequest) return "Wait a minute before retrying."; nextVerificationRequest = Date.now() + 60_000; const { error } = await authClient.sendVerificationEmail({ email: emailInput.value, callbackURL: "/", }); return error ? "Could not request verification. Try again later." : "If this address needs verification, check its inbox.";});Use a neutral response instead of displaying whether the address exists. This cooldown is only a UI convenience; keep Better Auth’s server rate limiting enabled.
Request a password reset
Section titled “Request a password reset”Append to src/client.ts:
button("Request password reset", async () => { const { error } = await authClient.requestPasswordReset({ email: emailInput.value, redirectTo: "/", }); return error ? "Could not request a reset. Try again later." : "If this email has an account, check its inbox for a reset link.";});Better Auth sends a single-use reset link without making the UI disclose account existence. The link first reaches /api/auth/reset-password/<token>, then returns to this page with the reset token.
Receive the recovery token
Section titled “Receive the recovery token”Append to src/client.ts:
const query = new URLSearchParams(window.location.search);let resetToken = query.get("token");const invalidEmailLink = query.has("error");window.history.replaceState(null, "", window.location.pathname);Capture Better Auth’s standard email-link parameters, then remove them from the address bar; the token stays only in memory, so reloading requires reopening the email link. This handles the email flow’s own redirect parameters, not a custom OAuth callback error page.
Save the replacement password
Section titled “Save the replacement password”Append to src/client.ts:
button("Save new password", async () => { if (invalidEmailLink || !resetToken) return "Open a new password reset link first."; const { error } = await authClient.resetPassword({ token: resetToken, newPassword: passwordInput.value, }); if (error) return "Could not reset your password. Request a new link."; resetToken = null; return "Password reset. Sign in with your new password.";});After opening the email link, enter the new password and choose Save new password. The configured recovery policy invalidates existing sessions; sign in again with the new password.
Add a session check
Section titled “Add a session check”Append to src/client.ts:
button("Check session", readAccount);This control lets the user check the session again after signing in or resetting a password.
Sign out
Section titled “Sign out”Append to src/client.ts:
button("Sign out", async () => { const { error } = await authClient.signOut(); return error ? "Could not sign out. Try again." : "Signed out.";});The same-origin client ends the session without reading session cookies directly from JavaScript.
Show the initial page state
Section titled “Show the initial page state”Append this final block to src/client.ts:
if (invalidEmailLink) { status.textContent = "This email link is invalid or expired. Request a new one.";} else if (resetToken) { status.textContent = "Enter your new password and choose Save new password.";} else { void run(readAccount);}An incoming reset link prompts for a new password rather than being mistaken for a signed-in session. Ordinary visits check the session immediately.
Bundle the browser module
Section titled “Bundle the browser module”Run from the project root:
bun build ./src/client.ts --target browser --outdir ./publicThis produces the /client.js referenced by the HTML page. Re-run it whenever you change the browser module; it does not bundle src/auth.ts or the mail credentials into the client.
Start local development
Section titled “Start local development”bunx alchemy dev --stage email-guide --env-file .env.localOpen the URL Alchemy prints, register with an inbox you control, and keep the server running while following verification or recovery links. No fixed port or OAuth application is required; if the origin changes, request fresh messages instead of reusing old links.
Check the complete email flow
Section titled “Check the complete email flow”Register, open the verification email, return to the page, and sign in; Check session should now show your name. Request a password reset, open its email, save a replacement password, and confirm that the old session and password no longer work before signing in again.
For missing mail, inspect Resend’s delivery status without logging message bodies or generated links. Also try an expired or already-used reset link and confirm it cannot reset the password again.
Configure the production origin
Section titled “Configure the production origin”Create a separate, gitignored .env.production:
AUTH_BASE_URL=https://auth.your-domain.exampleRESEND_API_KEY=replace-with-your-production-sending-keyAUTH_EMAIL_FROM="Auth <auth@your-verified-domain.example>"Replace the origin with the actual HTTPS Worker origin, without a trailing slash or /api/auth, and use real production mail credentials. AUTH_BASE_URL does not provision DNS or attach a domain: configure the hostname on the Worker separately, or use its actual workers.dev origin.
Deploy with production configuration
Section titled “Deploy with production configuration”After bundling the browser module, deploy from the project root:
bunx alchemy deploy --stage production --env-file .env.productionKeep production state and credentials separate from development, and test delivery against the deployed origin. This walkthrough serves the page and API together; a separate browser origin requires explicit trusted origins, credentialed CORS, and compatible cookie settings—never disable origin or CSRF checks to work around a mismatch.