1---
2title: Email and password
3description: Add D1-backed registration, real email verification, and password recovery to an Alchemy project.
4---
5
6Start with an existing Alchemy project and a [configured Cloudflare profile](/cloudflare/setup). This standalone walkthrough uses a Worker, D1, and Resend; every local module and browser file is introduced here.
7
8Create 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.
9
10## Add the authentication dependencies
11
12```sh
13bun add @alchemy.run/better-auth better-auth@1.7.5 kysely
14```
15
16Keep `@alchemy.run/better-auth` aligned with your existing Alchemy release.
17
18## Configure the mail sender
19
20Create `.env.local`:
21
22```dotenv
23RESEND_API_KEY=replace-with-your-sending-key
24AUTH_EMAIL_FROM="Auth <auth@your-verified-domain.example>"
25```
26
27In [Resend](https://resend.com/docs/api-reference/emails/send-email), 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.
28
29## Keep environment files private
30
31Append these entries to your existing `.gitignore` if they are not already covered:
32
33```text
34.env.local
35.env.production
36```
37
38Use 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.
39
40## Read mail configuration with Config
41
42Create `src/mail.ts`:
43
44```typescript
45import * as Config from "effect/Config";
46import * as Effect from "effect/Effect";
47import * as HttpClient from "effect/http/HttpClient";
48import * as HttpClientRequest from "effect/http/HttpClientRequest";
49import * as HttpClientResponse from "effect/http/HttpClientResponse";
50
51const mailConfig = Effect.gen(function* () {
52 const from = yield* Config.String("AUTH_EMAIL_FROM");
53 const apiKey = yield* Config.Redacted("RESEND_API_KEY");
54 const http = yield* HttpClient.HttpClient;
55 return { from, apiKey, http };
56});
57```
58
59Alchemy 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.
60
61## Send mail through Resend
62
63Append to `src/mail.ts`:
64
65```typescript
66export const makeSendMail = mailConfig.pipe(
67 Effect.map(({ from, apiKey, http }) =>
68 (to: string, subject: string, text: string) =>
69 http.execute(
70 HttpClientRequest.post("https://api.resend.com/emails").pipe(
71 HttpClientRequest.bearerToken(apiKey),
72 HttpClientRequest.bodyJsonUnsafe({ from, to: [to], subject, text }),
73 ),
74 ).pipe(
75 Effect.flatMap(HttpClientResponse.filterStatusOk),
76 Effect.flatMap((response) => response.text),
77 Effect.asVoid,
78 Effect.timeout("10 seconds"),
79 Effect.mapError(() => new Error("Email delivery failed")),
80 Effect.scoped,
81 Effect.runPromise,
82 ),
83 ),
84);
85```
86
87Each 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.
88
89## Construct verified email authentication
90
91Create `src/auth.ts`:
92
93```typescript
94import { BetterAuth } from "@alchemy.run/better-auth";
95import * as Config from "effect/Config";
96import * as Context from "effect/Context";
97import * as Effect from "effect/Effect";
98import * as Layer from "effect/Layer";
99import * as Option from "effect/Option";
100import { makeSendMail } from "./mail.ts";
101
102const makeAuth = Effect.gen(function* () {
103 const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option);
104 const sendMail = yield* makeSendMail;
105 return yield* BetterAuth({
106 basePath: "/api/auth",
107 baseURL: Option.getOrUndefined(baseURL),
108 emailAndPassword: { enabled: true, requireEmailVerification: true },
109 emailVerification: {
110 sendOnSignUp: true,
111 autoSignInAfterVerification: false,
112 sendVerificationEmail: ({ user, url }) =>
113 sendMail(user.email, "Verify your email", `Verify your email: ${url}`),
114 },
115 });
116});
117```
118
119Options 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.
120
121## Enable password recovery
122
123Apply this diff to `src/auth.ts`, replacing its `emailAndPassword` property:
124
125```diff lang="typescript"
126- emailAndPassword: { enabled: true, requireEmailVerification: true },
127+ emailAndPassword: {
128+ enabled: true,
129+ requireEmailVerification: true,
130+ revokeSessionsOnPasswordReset: true,
131+ sendResetPassword: ({ user, url }) =>
132+ sendMail(user.email, "Reset your password", `Reset your password: ${url}`),
133+ },
134```
135
136Recovery 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](https://www.better-auth.com/docs/authentication/email-password) for additional controls.
137
138## Expose Auth as an Effect service
139
140Append to `src/auth.ts`:
141
142```typescript
143export class Auth extends Context.Service<
144 Auth,
145 Effect.Success<typeof makeAuth>
146>()("app/Auth") {
147 static readonly layer = Layer.effect(Auth, makeAuth);
148}
149```
150
151The 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.
152
153## Declare the database
154
155Create `src/database.ts`:
156
157```typescript
158import * as Cloudflare from "alchemy/Cloudflare";
159
160export const AuthDb = Cloudflare.D1.Database("AuthDb");
161```
162
163This declares the D1 resource without embedding a database client in the Auth options. The Worker will connect it through `CloudflareD1`.
164
165## Forward authentication requests
166
167Create `src/handler.ts`:
168
169```typescript
170import * as Effect from "effect/Effect";
171import { HttpServerRequest } from "effect/http/HttpServerRequest";
172import * as HttpServerResponse from "effect/http/HttpServerResponse";
173import { Auth } from "./auth.ts";
174
175export const handlers = Effect.gen(function* () {
176 const auth = yield* Auth;
177 return {
178 fetch: Effect.gen(function* () {
179 const request = yield* HttpServerRequest;
180 const path = request.url.split("?")[0];
181 if (path === "/api/auth" || path.startsWith("/api/auth/")) {
182 return yield* auth.fetch;
183 }
184 return HttpServerResponse.text("Not found", { status: 404 });
185 }),
186 };
187});
188```
189
190Pass 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.
191
192## Supply the Worker dependencies
193
194Create `src/worker.ts`:
195
196```typescript
197import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
198import * as Cloudflare from "alchemy/Cloudflare";
199import * as Effect from "effect/Effect";
200import * as Layer from "effect/Layer";
201import * as FetchHttpClient from "effect/http/FetchHttpClient";
202import { Auth } from "./auth.ts";
203import { AuthDb } from "./database.ts";
204import { handlers } from "./handler.ts";
205
206export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()(
207 "AuthWorker",
208 {
209 main: import.meta.url,
210 compatibility: { flags: ["nodejs_compat"] },
211 assets: { directory: "./public", runWorkerFirst: ["/api/*"] },
212 },
213 handlers.pipe(Effect.provide(Auth.layer.pipe(
214 Layer.provide(CloudflareD1(AuthDb)),
215 Layer.provide(FetchHttpClient.layer),
216 ))),
217) {}
218```
219
220The 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.
221
222## Define the stack
223
224Use this `alchemy.run.ts` for the walkthrough, or add `AuthWorker` to your existing stack while retaining its state backend:
225
226```typescript
227import * as Alchemy from "alchemy";
228import * as Cloudflare from "alchemy/Cloudflare";
229import * as Effect from "effect/Effect";
230import AuthWorker from "./src/worker.ts";
231
232export default Alchemy.Stack(
233 "EmailAuth",
234 { providers: Cloudflare.providers(), state: Alchemy.localState() },
235 Effect.gen(function* () {
236 const worker = yield* AuthWorker;
237 return { url: worker.url };
238 }),
239);
240```
241
242Preserve 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.
243
244## Create the browser form
245
246Create `public/index.html`:
247
248```html
249<!doctype html>
250<html lang="en">
251 <head>
252 <meta charset="utf-8">
253 <meta name="viewport" content="width=device-width, initial-scale=1">
254 <meta name="referrer" content="no-referrer">
255 <title>Email sign-in</title>
256 </head>
257 <body>
258 <h1>Email sign-in</h1>
259 <form id="credentials">
260 <p><label>Name <input id="name" autocomplete="name"></label></p>
261 <p><label>Email <input id="email" type="email" autocomplete="email"></label></p>
262 <p><label>Password <input id="password" type="password" minlength="8" maxlength="128" autocomplete="off"></label></p>
263 </form>
264 <p id="status" role="status"></p>
265 <script type="module" src="/client.js"></script>
266 </body>
267</html>
268```
269
270The 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.
271
272## Protect pages containing recovery links
273
274Create `public/_headers`:
275
276```text
277/*
278 Referrer-Policy: no-referrer
279 Cache-Control: no-store
280```
281
282Cloudflare 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.
283
284## Connect the browser client
285
286Create `src/client.ts`:
287
288```typescript
289import { createAuthClient } from "better-auth/client";
290
291const authClient = createAuthClient();
292const form = document.querySelector<HTMLFormElement>("#credentials")!;
293const nameInput = document.querySelector<HTMLInputElement>("#name")!;
294const emailInput = document.querySelector<HTMLInputElement>("#email")!;
295const passwordInput = document.querySelector<HTMLInputElement>("#password")!;
296const status = document.querySelector<HTMLParagraphElement>("#status")!;
297form.addEventListener("submit", (event) => event.preventDefault());
298```
299
300`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.
301
302## Display action results safely
303
304Append to `src/client.ts`:
305
306```typescript
307async function run(action: () => Promise<string>) {
308 const buttons = document.querySelectorAll<HTMLButtonElement>("button");
309 buttons.forEach((element) => { element.disabled = true; });
310 try {
311 status.textContent = await action();
312 } catch {
313 status.textContent = "Could not reach the server. Try again.";
314 } finally {
315 passwordInput.value = "";
316 buttons.forEach((element) => { element.disabled = false; });
317 }
318}
319```
320
321Render messages as text, clear the password after each action, and prevent overlapping button submissions. Never store passwords in browser storage.
322
323## Add a reusable action button
324
325Append to `src/client.ts`:
326
327```typescript
328function button(label: string, action: () => Promise<string>) {
329 const element = document.createElement("button");
330 element.type = "button";
331 element.textContent = label;
332 element.addEventListener("click", () => void run(action));
333 document.body.append(element);
334}
335```
336
337Each button below runs through the same error display and password cleanup. The HTML page needs no inline event handlers.
338
339## Read the current session
340
341Append to `src/client.ts`:
342
343```typescript
344async function readAccount(): Promise<string> {
345 const { data, error } = await authClient.getSession();
346 if (error) return "Could not check your session. Try again.";
347 return data ? `Signed in as ${data.user.name}.` : "Verify your email, then sign in.";
348}
349```
350
351Read 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.
352
353## Register an account
354
355Append to `src/client.ts`:
356
357```typescript
358button("Register", async () => {
359 const { error } = await authClient.signUp.email({
360 name: nameInput.value,
361 email: emailInput.value,
362 password: passwordInput.value,
363 callbackURL: "/",
364 });
365 return error
366 ? "Could not register. Check your details and try again."
367 : "Check your inbox to verify your email, then sign in.";
368});
369```
370
371Registration 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.
372
373## Sign in with verified credentials
374
375Append to `src/client.ts`:
376
377```typescript
378button("Sign in", async () => {
379 const { data, error } = await authClient.signIn.email({
380 email: emailInput.value,
381 password: passwordInput.value,
382 });
383 if (error?.code === "EMAIL_NOT_VERIFIED") return "Verify your email first.";
384 if (error) return "Could not sign in. Check your email and password.";
385 if (!data?.user) return "Sign-in did not finish. Try again.";
386 return readAccount();
387});
388```
389
390The server refuses unverified sign-ins and issues the session cookie after successful authentication. Delivery failures are not a reason to disable verification.
391
392## Request another verification message
393
394Append to `src/client.ts`:
395
396```typescript
397let nextVerificationRequest = 0;
398button("Resend verification", async () => {
399 if (Date.now() < nextVerificationRequest) return "Wait a minute before retrying.";
400 nextVerificationRequest = Date.now() + 60_000;
401 const { error } = await authClient.sendVerificationEmail({
402 email: emailInput.value,
403 callbackURL: "/",
404 });
405 return error
406 ? "Could not request verification. Try again later."
407 : "If this address needs verification, check its inbox.";
408});
409```
410
411Use a neutral response instead of displaying whether the address exists. This cooldown is only a UI convenience; keep Better Auth's server [rate limiting](https://www.better-auth.com/docs/concepts/rate-limit) enabled.
412
413## Request a password reset
414
415Append to `src/client.ts`:
416
417```typescript
418button("Request password reset", async () => {
419 const { error } = await authClient.requestPasswordReset({
420 email: emailInput.value,
421 redirectTo: "/",
422 });
423 return error
424 ? "Could not request a reset. Try again later."
425 : "If this email has an account, check its inbox for a reset link.";
426});
427```
428
429Better 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.
430
431## Receive the recovery token
432
433Append to `src/client.ts`:
434
435```typescript
436const query = new URLSearchParams(window.location.search);
437let resetToken = query.get("token");
438const invalidEmailLink = query.has("error");
439window.history.replaceState(null, "", window.location.pathname);
440```
441
442Capture 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.
443
444## Save the replacement password
445
446Append to `src/client.ts`:
447
448```typescript
449button("Save new password", async () => {
450 if (invalidEmailLink || !resetToken) return "Open a new password reset link first.";
451 const { error } = await authClient.resetPassword({
452 token: resetToken,
453 newPassword: passwordInput.value,
454 });
455 if (error) return "Could not reset your password. Request a new link.";
456 resetToken = null;
457 return "Password reset. Sign in with your new password.";
458});
459```
460
461After 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.
462
463## Add a session check
464
465Append to `src/client.ts`:
466
467```typescript
468button("Check session", readAccount);
469```
470
471This control lets the user check the session again after signing in or resetting a password.
472
473## Sign out
474
475Append to `src/client.ts`:
476
477```typescript
478button("Sign out", async () => {
479 const { error } = await authClient.signOut();
480 return error ? "Could not sign out. Try again." : "Signed out.";
481});
482```
483
484The same-origin client ends the session without reading session cookies directly from JavaScript.
485
486## Show the initial page state
487
488Append this final block to `src/client.ts`:
489
490```typescript
491if (invalidEmailLink) {
492 status.textContent = "This email link is invalid or expired. Request a new one.";
493} else if (resetToken) {
494 status.textContent = "Enter your new password and choose Save new password.";
495} else {
496 void run(readAccount);
497}
498```
499
500An incoming reset link prompts for a new password rather than being mistaken for a signed-in session. Ordinary visits check the session immediately.
501
502## Bundle the browser module
503
504Run from the project root:
505
506```sh
507bun build ./src/client.ts --target browser --outdir ./public
508```
509
510This 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.
511
512## Start local development
513
514```sh
515bunx alchemy dev --stage email-guide --env-file .env.local
516```
517
518Open 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.
519
520## Check the complete email flow
521
522Register, 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.
523
524For 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.
525
526## Configure the production origin
527
528Create a separate, gitignored `.env.production`:
529
530```dotenv
531AUTH_BASE_URL=https://auth.your-domain.example
532RESEND_API_KEY=replace-with-your-production-sending-key
533AUTH_EMAIL_FROM="Auth <auth@your-verified-domain.example>"
534```
535
536Replace 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.
537
538## Deploy with production configuration
539
540After bundling the browser module, deploy from the project root:
541
542```sh
543bunx alchemy deploy --stage production --env-file .env.production
544```
545
546Keep 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.