Testing Supabase OTP Authentication in Playwright: A Database Trigger Solution

By amillionmonkeys
#Supabase#Playwright#Testing#Authentication#E2E Testing

Skip the mocking headache. Learn how to test Supabase OTP authentication in Playwright using a clever database trigger that makes testing seamless.

If you've tried to test Supabase OTP authentication with Playwright, you've probably hit the same wall we did: how do you handle email verification codes in automated tests without resorting to complex mocking or email interception?

The conventional wisdom says you need to either mock Supabase entirely (breaking the authenticity of your tests) or set up an email interceptor like Inbucket (adding infrastructure complexity). But there's a better way.

We discovered a remarkably elegant solution using a Supabase database trigger that eliminates mocking while keeping your tests reliable and your integration authentic. Here's how we test real OTP flows in production without any of the usual headaches.

The OTP Testing Problem

Testing authentication flows with one-time passwords is genuinely difficult. Here's what you're up against:

The Email Problem: When Supabase sends an OTP email, how does your automated test retrieve that code? You can't check a real inbox, and waiting for emails adds flakiness.

The Mocking Trap: You could mock the Supabase client to fake authentication. But now you're not testing the real flow. Your tests pass while your actual auth might be broken.

The Infrastructure Overhead: Email interceptors work, but they add complexity. You need another service running, more configuration, parsing email HTML, and handling race conditions when emails arrive.

The Playwright-Specific Pain: Playwright excels at testing real user journeys. But OTP flows break that model because the verification code arrives outside your application—in an email your test can't access.

We needed something different: a way to test the complete, real Supabase authentication flow while having predictable OTP codes in our tests.

The Database Trigger Solution

Here's the insight that changed everything: Supabase stores recovery tokens directly in the auth.users table. What if we could intercept that process for test emails and set a known, predictable token?

Enter database triggers. We created a trigger that watches for test email addresses and automatically sets their recovery token to a deterministic value. The code is surprisingly simple:

CREATE OR REPLACE FUNCTION auth.set_test_recovery_token()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.email LIKE '%@example.com' THEN
        NEW.recovery_token := encode(sha224(concat(NEW.email,'123456')::bytea), 'hex');
        NEW.recovery_sent_at := now();
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
 
CREATE TRIGGER set_test_recovery_token_trigger
    BEFORE INSERT OR UPDATE ON auth.users
    FOR EACH ROW
    EXECUTE FUNCTION auth.set_test_recovery_token();

Let's unpack what's happening here:

  1. Email Pattern Matching: The trigger only fires for emails ending in @example.com. Your real users are never affected.

  2. Deterministic Token Generation: We hash the email address plus the string "123456" using SHA-224. This produces the exact token Supabase expects for the code "123456".

  3. Timestamp Update: Setting recovery_sent_at ensures Supabase treats this as a valid, recently-sent OTP.

The brilliance? Your tests now use "123456" as the OTP for any @example.com email. No mocking, no email checking, no waiting. Just a hardcoded value that actually works with real Supabase authentication.

How It Works in Practice

Here's what happens when a Playwright test runs:

test("user can login with OTP", async ({ page }) => {
  const testEmail = "[email protected]";
 
  // Navigate to login page
  await page.goto("/login");
 
  // Enter email and request OTP
  await page.getByLabel("Email").fill(testEmail);
  await page.getByRole("button", { name: "Send code" }).click();
 
  // Wait for verification page
  await page.waitForURL(/verify/);
 
  // Enter the magic code that always works for test emails
  await page.getByTestId("code-input").fill("123456");
  await page.getByRole("button", { name: "Verify" }).click();
 
  // User is now authenticated
  await page.waitForURL("/");
  await expect(page.getByText("Welcome")).toBeVisible();
});

Behind the scenes:

  1. Supabase receives the OTP request for [email protected]
  2. The database trigger intercepts the user record update
  3. Sets recovery_token to the hash of "[email protected]" + "123456"
  4. Your test enters "123456"
  5. Supabase validates the code against the stored token
  6. Authentication succeeds—using the real auth flow

No mocks. No interceptors. Just real authentication that happens to use predictable values for test accounts.

Setting It Up

Step 1: Create the Database Trigger

In your Supabase dashboard, go to the SQL Editor and run the trigger creation script above. That's it. The trigger is now active.

If you're using migrations (and you should be), save this as a migration file:

-- migrations/20240101_test_otp_trigger.sql
CREATE OR REPLACE FUNCTION auth.set_test_recovery_token()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.email LIKE '%@example.com' THEN
        NEW.recovery_token := encode(sha224(concat(NEW.email,'123456')::bytea), 'hex');
        NEW.recovery_sent_at := now();
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
 
CREATE TRIGGER set_test_recovery_token_trigger
    BEFORE INSERT OR UPDATE ON auth.users
    FOR EACH ROW
    EXECUTE FUNCTION auth.set_test_recovery_token();

Step 2: Create Test Helpers

Keep your test code DRY with helper functions:

// e2e/helpers/auth-helpers.ts
import { Page } from "@playwright/test";
 
export class AuthTestHelpers {
  constructor(private page: Page) {}
 
  // Generate unique test emails to prevent conflicts
  static generateTestEmail(prefix: string = "test"): string {
    const timestamp = Date.now();
    return `${prefix}+${timestamp}@example.com`;
  }
 
  async loginWithEmail(email: string) {
    await this.page.goto("/login");
    await this.page.getByLabel("Email").fill(email);
    await this.page.getByRole("button", { name: "Send code" }).click();
  }
 
  async verifyOnVerificationPage(email: string) {
    await this.page.waitForURL(/verify/);
    await expect(this.page.getByText(`Code sent to ${email}`)).toBeVisible();
  }
 
  async enterVerificationCode(code: string) {
    await this.page.getByTestId("code-input").fill(code);
    await this.page.getByRole("button", { name: "Verify" }).click();
  }
 
  async completeOTPLogin(email: string) {
    await this.loginWithEmail(email);
    await this.verifyOnVerificationPage(email);
    await this.enterVerificationCode("123456");
    await this.page.waitForURL("/");
  }
}
 
// Test data constants
export const TestData = {
  codes: {
    valid: "123456", // Works for all @example.com emails
    invalid: "000000", // For testing error states
  },
};

Step 3: Write Your Tests

With the helpers in place, your tests become clean and focused:

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
import { AuthTestHelpers, TestData } from "./helpers/auth-helpers";
 
test.describe("OTP Authentication", () => {
  let authHelpers: AuthTestHelpers;
 
  test.beforeEach(async ({ page }) => {
    authHelpers = new AuthTestHelpers(page);
  });
 
  test("allows user to login with valid OTP", async ({ page }) => {
    const email = AuthTestHelpers.generateTestEmail();
 
    await authHelpers.loginWithEmail(email);
    await authHelpers.verifyOnVerificationPage(email);
    await authHelpers.enterVerificationCode(TestData.codes.valid);
 
    // Verify successful authentication
    await page.waitForURL("/");
    await expect(page.getByText("Welcome")).toBeVisible();
  });
 
  test("shows error for invalid OTP", async ({ page }) => {
    const email = AuthTestHelpers.generateTestEmail();
 
    await authHelpers.loginWithEmail(email);
    await authHelpers.verifyOnVerificationPage(email);
    await authHelpers.enterVerificationCode(TestData.codes.invalid);
 
    // Verify error message
    await expect(page.getByText("Invalid verification code")).toBeVisible();
  });
 
  test("allows multiple login attempts", async ({ page }) => {
    const email = AuthTestHelpers.generateTestEmail();
 
    await authHelpers.loginWithEmail(email);
    await authHelpers.verifyOnVerificationPage(email);
 
    // Try invalid code first
    await authHelpers.enterVerificationCode(TestData.codes.invalid);
    await expect(page.getByText("Invalid")).toBeVisible();
 
    // Then use valid code
    await authHelpers.enterVerificationCode(TestData.codes.valid);
    await page.waitForURL("/");
  });
});

Step 4: Configure Playwright

Make sure your Playwright config is set up for the Supabase connection:

// playwright.config.ts
import { defineConfig } from "@playwright/test";
 
export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
 
  use: {
    baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || "http://localhost:3001",
    trace: "on-first-retry",
    ignoreHTTPSErrors: true, // For local Supabase
  },
 
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3001",
    reuseExistingServer: !process.env.CI,
  },
});

Why This Approach Works

Real Authentication Flow

You're testing the actual Supabase authentication mechanism. If there's a bug in your auth implementation, your tests will catch it. Compare this to mocking, where tests pass even if the real integration is broken.

Zero Infrastructure Overhead

No email interceptor services. No additional containers. No parsing email HTML. Just a database trigger that runs invisibly in the background.

Deterministic and Fast

Tests never wait for emails. They never deal with email delivery delays or race conditions. The OTP is always "123456" for test accounts, period.

Test Independence

Each test can generate a unique email address using timestamps. Tests don't interfere with each other, and they can run in parallel without conflicts:

const email1 = AuthTestHelpers.generateTestEmail();
// Returns: [email protected]
 
const email2 = AuthTestHelpers.generateTestEmail();
// Returns: [email protected]

Production Safety

The trigger only affects @example.com emails. Your real users' authentication is completely unchanged. There's zero risk of this testing mechanism affecting production behavior.

Bonus: Mobile App Store Reviews

Here's an unexpected benefit we discovered: this solution also smooths mobile app store review.

When submitting our React Native app, we could provide Apple and Google reviewers with test credentials:

Email: [email protected]
OTP Code: 123456

The reviewers log in successfully using the real authentication flow—no special reviewer builds, no mock modes, no temporary backdoors. The app they review is identical to what users get, just with a test email domain.

This eliminated back-and-forth with app review teams asking for working credentials or encountering authentication issues.

Security Considerations

Only use this in non-production environments. The trigger should exist in your development and testing databases, not production.

If you're using Supabase projects for different environments:

  • Development: Trigger active
  • Staging: Trigger active
  • Production: No trigger

You can manage this through your migration strategy:

// Only run test trigger in non-production
if (process.env.ENVIRONMENT !== "production") {
  await runMigration("20240101_test_otp_trigger.sql");
}

The @example.com domain is key. This is a reserved domain for testing and documentation (RFC 2606). Using it ensures you never accidentally affect real email addresses.

When to Use This Approach

This technique is perfect when:

  • You're testing OTP or magic link authentication
  • You want to test real Supabase flows, not mocks
  • You need fast, deterministic tests
  • You're running tests in isolated environments
  • You want simple CI/CD integration

It's less suitable when:

  • You need to test email content or formatting
  • You're specifically testing email delivery systems
  • You need to test different OTP code values
  • You're testing email provider integrations

Alternative Approaches

For completeness, here are other approaches we considered:

Email Interceptors (Inbucket, MailHog): These work but add infrastructure. You need to run another service, configure Supabase to use it, parse emails, and extract codes. More moving parts mean more failure points.

Supabase Mocking: You can mock the Supabase client to fake authentication. But you lose confidence that your real integration works. We've seen too many cases where mocked tests passed but production auth was broken.

Test Mode Flags: Adding special "test mode" logic to your application. This works but pollutes your codebase with test-specific code paths. The trigger approach keeps test logic in the database, separate from application code.

Implementation Checklist

Ready to try this? Here's your step-by-step:

  • Create the database trigger in your test Supabase instance
  • Add the trigger to your migration files
  • Create test helper functions for OTP flow
  • Update existing auth tests to use @example.com emails
  • Add constants for test OTP codes
  • Test both valid and invalid OTP scenarios
  • Verify tests run in parallel without conflicts
  • Document the approach for your team
  • Update CI/CD to apply trigger in test environments
  • Confirm trigger doesn't exist in production

The Bottom Line

Testing OTP authentication doesn't have to be complicated. With a simple database trigger, you get:

  • Real Supabase authentication testing
  • Fast, deterministic test runs
  • No mocking or infrastructure overhead
  • Production-safe implementation
  • Bonus mobile app review benefits

We've used this approach across multiple projects, including apps with thousands of users. The tests are reliable, the setup is simple, and we sleep better knowing we're testing the real authentication flow.

If you're currently wrestling with Playwright + Supabase OTP testing, give this trigger approach a try. It's one of those solutions that feels almost too simple—until you realize it solves the problem completely.

Need help setting up Playwright tests for your Supabase application? We've implemented this pattern across dozens of projects. Get in touch and we'll help you get reliable E2E tests running quickly.

T: 07512 944360 | E: [email protected]

© 2025 amillionmonkeys ltd. All rights reserved.