Skip to content

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.

Terminal window
bun add @alchemy.run/better-auth better-auth@1.7.5 kysely

Keep @alchemy.run/better-auth aligned with your existing Alchemy release.

Create .env.local:

RESEND_API_KEY=replace-with-your-sending-key
AUTH_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.

Append these entries to your existing .gitignore if they are not already covered:

.env.local
.env.production

Use 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.

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.

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.

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.

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.

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.

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.

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.

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.

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 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.

Create public/_headers:

/*
Referrer-Policy: no-referrer
Cache-Control: no-store

Cloudflare 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

Run from the project root:

Terminal window
bun build ./src/client.ts --target browser --outdir ./public

This 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.

Terminal window
bunx alchemy dev --stage email-guide --env-file .env.local

Open 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.

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.

Create a separate, gitignored .env.production:

AUTH_BASE_URL=https://auth.your-domain.example
RESEND_API_KEY=replace-with-your-production-sending-key
AUTH_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.

After bundling the browser module, deploy from the project root:

Terminal window
bunx alchemy deploy --stage production --env-file .env.production

Keep 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.