Skip to main content
Back to Blog
By Mahmut Jomaa8 min read

Better Auth End-to-End Testing with Playwright in Next.js 16

Build reliable Playwright tests for Better Auth sign-in, TOTP, organization access, AI credit enforcement and admin authorization in a production Next.js SaaS.

Authentication, two-factor and organization checkpoints connected to a verified test result
On this page11 sections

An authentication page can render perfectly while the product behind it is broken. The session may not survive a redirect. Two-factor enrollment may succeed but the next sign-in may fail. An organization member may reach another tenant's route. A regular user may see an administrator page.

Those are integration failures, so they need integration-level evidence.

This guide builds a focused Playwright suite around the authentication and authorization flows that matter in a production Next.js SaaS. The examples come from the end-to-end tests shipped in both Achromatic Pro starter kits, with the same behavior covered in the Prisma and Drizzle editions.

Test outcomes, not authentication components

Playwright recommends testing user-visible behavior instead of implementation details. That distinction is especially useful for authentication. Your suite should not care which React component owns the email field; it should prove what a user can do after the server accepts their credentials.

A compact SaaS authentication matrix can look like this:

BoundaryBehavior to prove
Credential sign-inValid credentials create a session and reach the dashboard
SessionAuthenticated navigation stays authenticated
Two-factor authenticationA user can enroll and must provide a valid TOTP on the next sign-in
Organization accessA member can enter the expected workspace routes
Product entitlementA server-enforced credit or plan limit blocks an unavailable action
Global administrationAn application administrator can reach protected admin surfaces

This is deliberately smaller than a test for every page. E2E tests are most valuable at boundaries where the browser, application server, authentication library and database must agree.

Start every test from known database state

Authentication tests mutate durable state: sessions are created, TOTP secrets are stored and organization memberships are read. Reusing whatever happens to be in a developer database makes failures difficult to reproduce.

The Achromatic suite runs an idempotent seed before each authenticated scenario:

tests/e2e/application.spec.ts
import { execFileSync } from 'node:child_process';
import { test } from '@playwright/test';

test.describe.configure({ mode: 'serial' });

test.beforeEach(() => {
  execFileSync(process.execPath, ['--env-file=.env', 'tests/e2e/seed.mjs']);
});

The seed creates two identities:

  • An organization owner with a known credential account and membership
  • An application administrator with a known credential account

It also resets mutable two-factor state. Re-running the suite therefore starts with the same users, password hashes, roles and organization on every pass.

Keep test-only identities local to the test environment. Do not seed predictable credentials into production, and never commit a Playwright storage-state file containing live cookies. The Playwright authentication guide warns that saved browser state can impersonate the account it belongs to.

Use a small sign-in helper

The sign-in helper should cross the public UI and assert the resulting session boundary. Keep it boring:

tests/e2e/application.spec.ts
import { expect, type Page } from '@playwright/test';

async function signIn(page: Page, email: string) {
  await page.goto('/auth/sign-in');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password', { exact: true }).fill('E2e-password-123!');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
}

Role and label locators describe what the user interacts with and remain more resilient than selectors coupled to CSS classes or DOM nesting.

Do not hide the entire journey behind a large helper. The test should still make the important transition legible: which identity signed in, which route it opened and what authorization outcome it observed.

Prove the session across organization routes

Reaching /dashboard proves only the initial redirect. A useful B2B test continues into organization-scoped product surfaces:

tests/e2e/application.spec.ts
test('owner can navigate account and organization surfaces', async ({
  page
}) => {
  await signIn(page, 'owner@e2e.local');

  await expect(
    page.getByRole('heading', { name: 'Your Organizations' })
  ).toBeVisible();

  await page.getByText('Open', { exact: true }).click();
  await expect(page).toHaveURL(/\/dashboard\/organization/);

  for (const path of ['leads', 'settings', 'chatbot']) {
    await page.goto(`/dashboard/organization/${path}`);
    await expect(page).not.toHaveURL(/auth\/sign-in/);
  }
});

This single scenario exercises the credential flow, session cookie, organization membership and protected route handling. It complements narrower unit tests around permission functions.

Better Auth's organization plugin supports organizations, members, invitations, teams and access control. Your application still owns the product-specific question: which organization is active, which records belong to it and which routes a member may open. Test those decisions through your application, not just the plugin API.

Test TOTP enrollment and the next sign-in

Two-factor testing often stops after the setup dialog says “enabled.” That misses the most important half of the flow: whether the next credential sign-in is challenged and accepts the enrolled factor.

TOTP is time based, so the test needs a real code generated from the secret shown during enrollment. A minimal generator uses the standard 30-second counter and HMAC-SHA1:

tests/e2e/application.spec.ts
import { createHmac } from 'node:crypto';

function totp(secret: string) {
  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
  let bits = '';

  for (const char of secret) {
    bits += alphabet.indexOf(char).toString(2).padStart(5, '0');
  }

  const key = Buffer.from(
    (bits.match(/.{8}/g) ?? []).map((byte) => Number.parseInt(byte, 2))
  );
  const counter = Buffer.alloc(8);
  counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)));

  const hash = createHmac('sha1', key).update(counter).digest();
  const offset = hash[19]! & 15;

  return ((hash.readUInt32BE(offset) & 0x7fffffff) % 1_000_000)
    .toString()
    .padStart(6, '0');
}

A code generated near the end of its window can expire while Playwright fills and submits the form. Avoid that timing flake by waiting for the next window when necessary:

tests/e2e/application.spec.ts
async function stableTotp(secret: string) {
  const elapsed = Date.now() % 30_000;

  if (elapsed > 15_000) {
    await new Promise((resolve) => setTimeout(resolve, 30_500 - elapsed));
  }

  return totp(secret);
}

The complete scenario should:

  1. Sign in and open security settings.
  2. Confirm the current password before enrollment.
  3. Read the Base32 secret shown by the application.
  4. Submit a stable TOTP and verify that two-factor authentication is enabled.
  5. Sign out and clear browser cookies.
  6. Sign in again with the same credentials.
  7. Assert the redirect to the verification route.
  8. Submit a fresh TOTP and assert access to the dashboard.

Better Auth documents the TOTP verification flow and its accepted time windows in the two-factor authentication plugin guide. Running the enrollment and re-authentication journey in one scenario proves that the stored secret, redirect and session upgrade work together.

Treat authorization and entitlements as separate boundaries

Authentication answers who the user is. It does not prove what the user may do.

For a multi-tenant SaaS, add scenarios for at least two different authorization layers:

Product entitlement

The Achromatic AI chat test signs in as the organization owner, opens the chatbot, submits a message and expects Not enough credits. The valuable assertion is not that the input accepts text. It is that the server-enforced organization credit balance prevents the operation.

Testing the blocked case is important because optimistic UI alone can make an unavailable action appear successful.

Application administration

Organization roles and global application roles are not interchangeable. A separate admin identity verifies the protected application-level surfaces:

tests/e2e/application.spec.ts
test('administrator can access every admin surface', async ({ page }) => {
  await signIn(page, 'admin@e2e.local');

  for (const [path, heading] of [
    ['users', 'Users'],
    ['organizations', 'Organizations'],
    ['app-config', 'App Config']
  ] as const) {
    await page.goto(`/dashboard/admin/${path}`);
    await expect(page.getByRole('heading', { name: heading })).toBeVisible();
  }
});

Pair this happy-path scenario with lower-level authorization tests for denial cases. A browser suite should cover the highest-risk role transitions without becoming the only place your permission rules are tested.

Let rate limiting stay real

Better Auth applies stricter rules to sensitive endpoints. Its documentation currently lists email sign-in as limited to three requests per ten seconds, separate from the broader default rate limit.

That protection can surprise an E2E suite that performs several credential sign-ins from the same local address. Disabling the limiter entirely makes the test environment less representative.

The Achromatic suite instead:

  • Runs authenticated, state-changing scenarios serially
  • Uses one Playwright worker
  • Waits for the short protection window to reset before a separate sign-in flow would exceed it

See the Better Auth rate-limit documentation before changing these timings. If your configuration uses different endpoint rules or distributed storage, align the test with that actual setup rather than copying a fixed delay.

Run the production build in CI

A development server can hide build-time and production-runtime problems. The shipped Playwright configuration builds and starts Next.js before opening Chromium:

playwright.config.ts
export default defineConfig({
  fullyParallel: false,
  retries: process.env.CI ? 1 : 0,
  workers: 1,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    video: {
      mode: 'retain-on-failure',
      size: { width: 640, height: 480 }
    }
  },
  webServer: {
    command: 'npm run build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 180_000
  }
});

Playwright's webServer configuration manages the application process and waits for its URL. Retaining a trace on the first retry and a video on failure gives CI enough evidence to diagnose redirects, dialogs and timing issues without recording every successful run.

Keep provider-owned flows in a staging smoke suite

The local suite described here does not claim to prove every external integration. Real email delivery, OAuth consent screens and payment-provider redirects depend on systems outside the local application.

Separate them by responsibility:

  • Local E2E: credentials, sessions, TOTP, organization access, roles and application-owned entitlements
  • Provider contract tests: payload construction, webhook signatures and application handlers
  • Staging smoke tests: real verification email delivery, password reset links, OAuth callbacks and payment redirects using dedicated test accounts

Do not mock the database or Better Auth inside the browser journey you are calling E2E. Mock only the external boundary when the goal is to test your application deterministically, then keep a smaller real-provider smoke suite for integration drift.

A practical authentication test checklist

Before treating the authentication path as release-ready, verify:

  • Test users, roles and organizations are created deterministically
  • Mutable TOTP and session state is reset between relevant scenarios
  • Locators describe visible labels, roles and outcomes
  • Credential sign-in ends with an authenticated route assertion
  • Organization navigation proves the intended tenant context
  • TOTP enrollment is followed by a fresh challenged sign-in
  • Entitlement limits are asserted at the user-visible boundary
  • Global admin access is tested separately from organization membership
  • Rate limits remain enabled and the suite respects their windows
  • CI runs a production build and retains useful failure artifacts
  • Saved authentication state and test secrets are excluded from Git
  • Email, OAuth and payment smoke tests run in an appropriate external environment

Start with the critical path

You do not need hundreds of browser tests to gain confidence in authentication. Begin with one deterministic identity per meaningful role and one scenario per high-risk boundary. Add a test when a failure could cross a tenant, bypass a factor, expose an admin surface or charge for an unavailable feature.

The Achromatic Pro Prisma and Drizzle editions ship the same focused Playwright coverage, so the testing model does not change when you choose a different ORM. Explore the complete setup in the Prisma E2E guide or Drizzle E2E guide.