playwright

How to Test Better Auth in Playwright (Email OTP, Magic Links, and 2FA)

10 min read

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:

  1. Static test tokens. Patch Better Auth to accept 000000 for @test.com. Congrats, you shipped a backdoor.
  2. Backend spy hooks. Inject a spy into OTP generation, grab the code before send. Great, now test code is in prod.
  3. 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

ApproachReal OTP deliveryReal code pathTime-travelSetup 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

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.