playwright
How to Test Better Auth in Playwright (Email OTP, Magic Links, and 2FA)
If you've built a Next.js app in the last six months, you've at least looked at Better Auth. It's fast. Headless. TypeScript-first. And unlike NextAuth's config-ception, it just works.
Then you sit down to write your first Playwright test for the sign-up flow. And you hit the same wall every auth library hits: how do you test an email OTP when it only lives in a real inbox?
This is the answer. We're covering:
- Testing Better Auth's Email OTP plugin end-to-end
- Testing magic-link sign-in
- Testing 2FA / MFA
- Running everything in CI without touching a real inbox
New to OTP testing entirely? Start with the ground-up Playwright OTP guide, then come back for the Better Auth specifics.
Why testing Better Auth is weird
Better Auth's plugin architecture is gorgeous in production and slightly awkward in tests. Every auth method (email OTP, magic link, passkey, 2FA, social) is its own plugin, with its own sendEmail/sendVerificationOTP callback that runs on your server.
So the flow looks like this:
- Better Auth generates the OTP on your backend
- Your callback ships it out (Resend, SendGrid, Postmark, SMTP, whatever)
- The email lands in a real inbox
- Your test needs to read that real inbox to type the code back
Most guides tell you to sidestep this with:
- Static test tokens. Patch Better Auth to accept
000000for@test.com. Congrats, you shipped a backdoor. - Backend spy hooks. Inject a spy into OTP generation, grab the code before send. Great, now test code is in prod.
- Real Gmail + IMAP. Reliable-ish until Google flags the login and you spend a morning re-authenticating.
There's a fourth option that ducks all three problems: catch the OTP at the delivery layer, before it ever hits a real inbox.
The setup
Install the SDK alongside Better Auth:
npm install --save-dev @otpmagiclink/playwright
Add your env:
# .env.test
OTP_API_KEY=sk_sandbox_your_key_here
OTP_SANDBOX_URL=https://otpmagiclink.com
Now the trick — route Better Auth's send function to the sandbox only in test mode:
// auth.ts
import { betterAuth } from "better-auth";
import { emailOTP } from "better-auth/plugins";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY!);
const isTest =
process.env.NODE_ENV === "test" || process.env.PLAYWRIGHT === "1";
export const auth = betterAuth({
emailAndPassword: { enabled: false },
plugins: [
emailOTP({
otpLength: 6,
expiresIn: 600, // 10 minutes
async sendVerificationOTP({ email, otp, type }) {
if (isTest) {
// Route to sandbox — real Better Auth OTP goes to test inbox
await fetch(`${process.env.OTP_SANDBOX_URL}/api/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: email,
channel: "EMAIL",
kind: "OTP",
metadata: { source: "better-auth", type, otp },
}),
});
return;
}
// Prod — real Resend send
await resend.emails.send({
from: "auth@yourapp.com",
to: email,
subject: `Your verification code`,
text: `Your code is: ${otp}. It expires in 10 minutes.`,
});
},
}),
],
});
That's the whole setup. Better Auth doesn't care that your callback writes to a sandbox instead of Resend — it just calls it and moves on.
Your first test
// tests/signup.spec.ts
import { test, expect } from "@playwright/test";
import { SandboxClient } from "@otpmagiclink/playwright";
const sandbox = new SandboxClient({
apiKey: process.env.OTP_API_KEY!,
});
test("sign up with Better Auth email OTP", async ({ page }) => {
// Unique email per test — parallel-safe
const email = `test-${Date.now()}@yourapp.com`;
// 1. Hit sign-up
await page.goto("/signup");
await page.fill("input[name=email]", email);
await page.click("button[type=submit]");
// 2. Wait for the OTP form to render
await expect(page.getByLabel(/verification code/i)).toBeVisible();
// 3. Grab the real OTP Better Auth just generated
const otp = await sandbox.waitForOtp(email);
// 4. Type it in
await page.fill("input[name=otp]", otp);
await page.click("button[type=submit]");
// 5. You're in
await expect(page).toHaveURL("/dashboard");
});
Six steps. No fake tokens. No bypasses. No regex. The OTP is generated by real Better Auth, captured by the sandbox, and returned by waitForOtp() in milliseconds.
Magic-link sign-in
Better Auth's magicLink plugin has the same shape — route the sendMagicLink callback to the sandbox:
// auth.ts (partial)
import { magicLink } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
magicLink({
expiresIn: 3600, // 1 hour
async sendMagicLink({ email, url }) {
if (isTest) {
await fetch(`${process.env.OTP_SANDBOX_URL}/api/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: email,
channel: "EMAIL",
kind: "MAGIC_LINK",
redirectUrl: url,
}),
});
return;
}
await resend.emails.send({
from: "auth@yourapp.com",
to: email,
subject: "Your sign-in link",
html: `<a href="${url}">Sign in</a>`,
});
},
}),
],
});
Then the test:
test("sign in with magic link", async ({ page }) => {
const email = `magic-${Date.now()}@yourapp.com`;
await page.goto("/signin");
await page.fill("input[name=email]", email);
await page.click("text=Send magic link");
await expect(page.getByText(/check your email/i)).toBeVisible();
// Grab the real magic-link URL Better Auth just generated
const link = await sandbox.waitForMagicLink(email);
// Simulate the user clicking the link in their inbox
await page.goto(link);
await expect(page).toHaveURL("/dashboard");
});
waitForMagicLink gives you the exact URL Better Auth put in the email. Navigate to it. You're in.
2FA (TOTP)
Better Auth's twoFactor plugin uses TOTP for the second factor — same math as Google Authenticator. No email involved, so the sandbox isn't in this loop. But you can still test it deterministically.
The trick: Better Auth exposes the TOTP secret when the user enrolls. Save it in your test setup and generate valid codes with otplib on demand:
import { authenticator } from "otplib";
test("sign in with 2FA", async ({ page }) => {
const email = `2fa-${Date.now()}@yourapp.com`;
// Assume you've seeded a test user with 2FA enrolled and
// stashed the secret in your test setup
const totpSecret = await getTestUserTotpSecret(email);
await page.goto("/signin");
await page.fill("input[name=email]", email);
await page.fill("input[name=password]", "test-password");
await page.click("button[type=submit]");
await expect(page.getByText(/enter your 2FA code/i)).toBeVisible();
// Real, valid TOTP code from the real secret
const totp = authenticator.generate(totpSecret);
await page.fill("input[name=totp]", totp);
await page.click("button[type=submit]");
await expect(page).toHaveURL("/dashboard");
});
Real math. Real secret. Real code. Same as what an authenticator app would generate. No mocking anywhere.
Testing OTP expiry
Better Auth's expiresIn controls OTP lifetime. Want to prove expiry actually works? Fast-forward the sandbox clock:
test("OTP is rejected after 10 minutes", async ({ page }) => {
const email = `expiry-${Date.now()}@yourapp.com`;
await page.goto("/signup");
await page.fill("input[name=email]", email);
await page.click("button[type=submit]");
const otp = await sandbox.waitForOtp(email);
// Time-travel 11 minutes into the future
await sandbox.advanceClock(11 * 60);
await page.fill("input[name=otp]", otp);
await page.click("button[type=submit]");
await expect(page.getByText(/code expired/i)).toBeVisible();
});
test.afterEach(async () => {
await sandbox.resetClock(); // don't leak time-travel to the next test
});
Same trick for magic-link expiry — advance past magicLink.expiresIn, click, assert failure.
CI
# .github/workflows/playwright.yml
name: Playwright
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
- run: npx playwright test
env:
NODE_ENV: test
OTP_API_KEY: ${{ secrets.OTP_API_KEY }}
OTP_SANDBOX_URL: https://otpmagiclink.com
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
The only unusual line is NODE_ENV: test — that flips your sendVerificationOTP into sandbox mode. Everything else is stock Playwright CI.
When something goes wrong
waitForOtp times out. The sandbox never got the OTP. Check isTest is true in CI, and that your fetch to OTP_SANDBOX_URL isn't failing silently. Drop a console.log in sendVerificationOTP to confirm the sandbox branch is running.
2FA test always fails. Your test's authenticator.generate() and Better Auth's TOTP validation need the same time window (30s by default). Both use otplib, so this should just work — usually the culprit is a drifted clock on the test machine.
Passes locally, red in CI. Almost always a race. Bump waitForOtp timeout to 15–20s and double-check DB seeds finish before your first auth call.
Magic-link URL points at the wrong host. Better Auth builds URLs from baseURL. Your test config needs the right baseURL — usually http://localhost:3000.
Vs. the alternatives
| Approach | Real OTP delivery | Real code path | Time-travel | Setup time |
|---|---|---|---|---|
Static test tokens (000000 bypass) | ❌ | ❌ (test-only branch) | ❌ | 5 min |
| Backend spy hook | ✅ | ⚠️ Test code in prod | ❌ | 30 min |
| Gmail + IMAP polling | ✅ | ✅ | ❌ | 2-3 hours + Google bot fights |
| MailSlurp / Mailosaur | ✅ | ✅ | ❌ | 30 min + regex-per-template maintenance |
| otpmagiclink | ✅ | ✅ | ✅ | 5 min |
FAQ
Does this work with Better Auth's passkey plugin?
Passkeys don't email anything — they use WebAuthn, browser-driven. For passkey tests, use Playwright's built-in virtualAuthenticator API. Sandbox isn't in the picture.
What if I use SendGrid, Postmark, or SMTP instead of Resend?
Doesn't matter. The sandbox sits at the sendVerificationOTP layer, which is upstream of your provider. Any downstream sender works.
Can I still use real Resend in E2E?
Yes. Just don't set NODE_ENV=test — the isTest check falls through to your real resend.emails.send(). Some teams run a nightly "real end-to-end" test hitting Resend + a real inbox to catch deliverability regressions.
Cypress or Vitest instead of Playwright?
Works. SandboxClient is a plain HTTP client — no Playwright dependency. Drop it into whatever runner you use.
Is the OTP the sandbox sees actually the same one Better Auth would send?
Yes. Better Auth generates the OTP, hands it to your callback, stores the hash for later verification. Your callback POSTs the actual OTP to the sandbox. Your test reads it and submits to Better Auth's verifyEmail endpoint, which checks against its stored hash. Full round-trip, real code path — nothing mocked.
Next up
- Grab a free API key at otpmagiclink.com — free forever for 1 project, no credit card
- See the full SDK reference
- Read the MailSlurp vs otpmagiclink comparison if you're still shopping
Testing Better Auth shouldn't involve hardcoded codes, backend bypasses, or flaky inboxes. Real OTPs, real magic links, real production code paths. Same test file.
Ship OTP + magic-link tests today
@otpmagiclink/playwright is a one-line SDK for waiting on OTPs and magic links in Playwright, Cypress, or any Node test runner. Free tier included.