Better Auth has become the go-to TypeScript authentication framework — email verification, magic links, OTP, password reset, all built in. But testing those email flows in CI is a problem nobody talks about.
Your app sends a verification email. Your Playwright test needs to read it, click the link, and assert the user is verified. Without a way to catch that email, you're either mocking it or skipping it entirely.
This guide shows how to test all four Better Auth email flows end to end with Playwright and ZeroDrop — no Docker, no shared inboxes, no regex.
The four flows we're testing
Better Auth ships these email flows out of the box:
-
Email verification — link sent on signup via
emailVerification -
Magic link — passwordless login via the
magicLinkplugin -
Email OTP — code-based login/verification via the
emailOTPplugin -
Password reset — reset link via
sendResetPassword
Every one of them sends a real email. Every one of them needs a real inbox to test properly.
Setup
Install the dependencies:
npm install zerodrop-client @playwright/test
npx playwright install chromium
No API key needed for ZeroDrop. No signup. No environment variables.
Better Auth config
Here's the full Better Auth setup with all four email flows:
// app/lib/auth.ts
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
import { emailOTP } from "better-auth/plugins";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
// Send via your email provider (Resend, Postmark, etc.)
await sendEmail({ to: user.email, subject: "Reset your password", text: url });
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({ to: user.email, subject: "Verify your email", text: url });
},
},
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({ to: email, subject: "Your magic link", text: url });
},
}),
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
await sendEmail({ to: email, subject: "Your verification code", text: `Your code: ${otp}` });
},
}),
],
});
Testing email verification on signup
Better Auth sends a verification link when a user signs up. ZeroDrop catches it, email.magicLink is auto-extracted:
import { test, expect } from "@playwright/test";
import { ZeroDrop } from "zerodrop-client";
const mail = new ZeroDrop();
test("email verification on signup", async ({ page }) => {
const inbox = mail.generateInbox();
// Sign up with a ZeroDrop inbox
await page.goto("/signup");
await page.fill('[name="email"]', inbox);
await page.fill('[name="password"]', "TestPassword123!");
await page.click('[type="submit"]');
// Better Auth shows "check your email" page
await expect(page.locator("text=Check your email")).toBeVisible();
// Wait for the verification email — arrives in <1s
const email = await mail.waitForLatest(inbox, {
timeout: 15000,
filter: { subject: "Verify" },
});
// magicLink auto-extracted — no regex needed
expect(email.magicLink).toBeTruthy();
// Navigate to the verification link
await page.goto(email.magicLink!);
// Better Auth verifies the user and redirects
await expect(page).toHaveURL("/dashboard");
});
Testing magic link login
test("magic link login", async ({ page }) => {
const inbox = mail.generateInbox();
// Request a magic link
await page.goto("/login");
await page.fill('[name="email"]', inbox);
await page.click('button:has-text("Send magic link")');
await expect(page.locator("text=Magic link sent")).toBeVisible();
// Catch the email
const email = await mail.waitForLatest(inbox, {
timeout: 15000,
filter: { hasMagicLink: true },
});
expect(email.magicLink).toBeTruthy();
// Navigate to the magic link — Better Auth signs the user in
await page.goto(email.magicLink!);
await expect(page).toHaveURL("/dashboard");
});
test("magic link is single-use", async ({ page, context }) => {
const inbox = mail.generateInbox();
await page.goto("/login");
await page.fill('[name="email"]', inbox);
await page.click('button:has-text("Send magic link")');
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
const magicLink = email.magicLink!;
// First use — succeeds
await page.goto(magicLink);
await expect(page).toHaveURL("/dashboard");
// Second use — Better Auth rejects the consumed token
const page2 = await context.newPage();
await page2.goto(magicLink);
await expect(page2.locator(".error-message")).toBeVisible();
});
Testing email OTP
Better Auth's emailOTP plugin sends a 6-digit code. email.otp is auto-extracted — no regex:
test("email OTP sign-in", async ({ page }) => {
const inbox = mail.generateInbox();
await page.goto("/login");
await page.fill('[name="email"]', inbox);
await page.click('button:has-text("Send code")');
await expect(page.locator("text=Code sent")).toBeVisible();
// OTP auto-extracted at Cloudflare's edge
const email = await mail.waitForLatest(inbox, {
timeout: 15000,
filter: { hasOtp: true },
});
expect(email.otp).toBeTruthy();
expect(email.otp).toMatch(/^\d{6}$/);
// Enter the code
await page.fill('[name="otp"]', email.otp!);
await page.click('[type="submit"]');
await expect(page).toHaveURL("/dashboard");
});
Testing password reset
test("password reset via email link", async ({ page }) => {
const inbox = mail.generateInbox();
// Request a password reset
await page.goto("/forgot-password");
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
// Catch the reset email
const email = await mail.waitForLatest(inbox, {
timeout: 15000,
filter: { subject: "Reset" },
});
expect(email.magicLink).toBeTruthy();
// Navigate to the reset link
await page.goto(email.magicLink!);
// Set new password
await page.fill('[name="password"]', "NewPassword123!");
await page.fill('[name="confirmPassword"]', "NewPassword123!");
await page.click('[type="submit"]');
await expect(page.locator("text=Password updated")).toBeVisible();
});
test("reset link is single-use", async ({ page, context }) => {
const inbox = mail.generateInbox();
await page.goto("/forgot-password");
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
const resetLink = email.magicLink!;
// First use — succeeds
await page.goto(resetLink);
await page.fill('[name="password"]', "NewPassword123!");
await page.click('[type="submit"]');
await expect(page.locator("text=Password updated")).toBeVisible();
// Second use — Better Auth rejects the consumed token
const page2 = await context.newPage();
await page2.goto(resetLink);
await expect(page2.locator(".error-message")).toBeVisible();
});
Parallel test runs — no collisions
generateInbox() runs locally with no network request. Each parallel worker gets a unique inbox automatically:
test.describe.configure({ mode: "parallel" });
test("user A signup", async ({ page }) => {
const inbox = mail.generateInbox(); // unique per worker
// ...
});
test("user B signup", async ({ page }) => {
const inbox = mail.generateInbox(); // different inbox, no collision
// ...
});
10 parallel workers. 10 isolated inboxes. Zero race conditions.
GitHub Actions CI
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
No Docker. No SMTP service. ZeroDrop works out of the box.
How email.otp and email.magicLink work
ZeroDrop catches emails at Cloudflare's edge before storing them. When a Better Auth email arrives, the worker extracts:
-
email.otp— 4-8 digit numeric code found near labels likecode,otp,verification -
email.magicLink— URL containingverify,confirm,reset,token, orauth
Both fields are null if not detected. Always assert before using:
expect(email.otp).not.toBeNull();
await page.fill('[name="otp"]', email.otp!);
Working example repo
All four tests are in a working example repo with the full Better Auth + Next.js app:
→ github.com/zerodrop-dev/better-auth-playwright-zerodrop
Clone it, add your .env, and run pnpm test.
Conclusion
Better Auth ships complete email flows out of the box. Testing them end to end doesn't require Docker, shared inboxes, or regex.
email.otp and email.magicLink — auto-extracted at the edge. Your test just reads them.
Free to use. No signup required. Works in CI out of the box.
→ zerodrop.dev · npm · GitHub
Top comments (0)