Testing Supabase OTP Authentication in Playwright: A Database Trigger Solution
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:
-
Email Pattern Matching: The trigger only fires for emails ending in
@example.com. Your real users are never affected. -
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".
-
Timestamp Update: Setting
recovery_sent_atensures 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:
- Supabase receives the OTP request for
[email protected] - The database trigger intercepts the user record update
- Sets
recovery_tokento the hash of "[email protected]" + "123456" - Your test enters "123456"
- Supabase validates the code against the stored token
- 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.comemails - 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.