Authentication Testing Guide

How to Test Supabase Auth End to End: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing Supabase Auth with Playwright. Email/password sign-up, magic link capture via Inbucket, OAuth through Supabase, phone OTP, Row Level Security verification, PKCE token refresh, and session persistence.

200K+

Supabase powers authentication for over 200,000 projects. Its auth system supports email/password, magic links, OAuth, and phone login, all behind a unified API that also enforces Row Level Security at the database layer.

0Auth Methods
0Test Scenarios
0Inbucket Email Capture
0RLS Verified

1. Why Testing Supabase Auth Is Tricky

Supabase Auth wraps GoTrue into a turnkey service that handles email/password, magic links, OAuth, and phone authentication. From the outside it looks simple: call supabase.auth.signInWithPassword() and you are done. But testing the full end-to-end flow, where a real browser navigates your UI, the auth server issues tokens, and Row Level Security policies gate every subsequent query, exposes several structural challenges that unit tests and mocked auth never catch.

First, magic link testing requires intercepting an email. In production, Supabase sends the link through an SMTP provider. In local development, the Supabase CLI bundles Inbucket, a mail capture server, but your test must know how to query the Inbucket REST API, extract the confirmation URL, and navigate to it before the token expires. Second, OAuth flows redirect through Supabase Auth (your-project.supabase.co/auth/v1/authorize) before bouncing to the third-party provider and back. That is three cross-origin hops your test driver has to handle cleanly. Third, Row Level Security means auth is not just about getting a token. It is about proving that, once authenticated, the user can only see their own data, and that an unauthenticated request sees nothing.

Fourth, Supabase uses the PKCE (Proof Key for Code Exchange) flow by default for browser-based auth. The code verifier is stored in the browser during the initial request and must match when the callback URL is loaded. If your test navigates the callback in a different browser context, the exchange fails silently. This guide walks through each of these challenges with runnable Playwright TypeScript and shows how to express each scenario in plain English with Assrt.

Magic Link Auth Flow

🌐

User enters email

⚙️

App calls signInWithOtp

📧

Supabase sends email

⚙️

Inbucket captures it

🔒

Test extracts link

↪️

Browser opens link

Token exchanged

OAuth Flow Through Supabase

🌐

User clicks OAuth button

↪️

Redirect to Supabase Auth

↪️

Redirect to provider

🔒

User approves

↪️

Provider callback

⚙️

Supabase exchanges code

Redirect to app

Magic Link: Full Token Exchange Sequence

BrowserYour AppSupabase AuthInbucketRLS DataEnter email, click SendsignInWithOtp()Send magic link emailTest extracts link via RESTOpen magic link URLToken verified (PKCE)Query with JWTRLS-filtered rows

2. Setting Up the Test Environment

Before writing any test, you need a local Supabase stack. The Supabase CLI (supabase start) spins up PostgreSQL, GoTrue, PostgREST, and Inbucket in Docker containers. Inbucket is the key piece: it is a local SMTP server with a REST API that captures every email Supabase sends, so your tests can retrieve magic links and confirmation tokens programmatically.

Local Supabase Setup Checklist

  • Install Docker Desktop and ensure it is running
  • Install the Supabase CLI globally: npm install -g supabase
  • Run supabase init in your project root
  • Run supabase start to spin up PostgreSQL, GoTrue, PostgREST, and Inbucket
  • Note the API URL, anon key, service role key, and Inbucket URL from the output
  • Set environment variables in .env.test
  • Install Playwright: npm install -D @playwright/test

Starting the Local Stack

supabase setup

Environment Variables

# .env.test
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...your-local-anon-key
SUPABASE_SERVICE_ROLE_KEY=eyJ...your-local-service-role-key
INBUCKET_URL=http://127.0.0.1:54324
APP_BASE_URL=http://localhost:3000

Inbucket Email Capture Helper

Every magic link test and every email confirmation test needs to read from Inbucket. Write a shared utility that polls the Inbucket REST API for the latest email sent to a given address and extracts the confirmation URL.

tests/helpers/inbucket.ts

Seeding Test Users

For tests that need a pre-existing user (sign-in tests, RLS tests), use the Supabase Admin API with the service role key to create users before each test run. This avoids depending on state from previous runs and keeps your tests deterministic.

// tests/helpers/seed-user.ts
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function seedTestUser(email: string, password: string) {
  const { data, error } = await supabaseAdmin.auth.admin.createUser({
    email,
    password,
    email_confirm: true, // Skip email verification
  });
  if (error) throw error;
  return data.user;
}

3. Scenario: Email/Password Sign-Up and Sign-In

1

Email/Password Sign-Up and Sign-In

Straightforward

This is your baseline smoke test. If email/password authentication breaks, every other auth method is suspect. The scenario covers both sign-up (new user) and sign-in (existing user), and it verifies the user lands on a protected page after authentication.

Goal

Create a new account with email and password, confirm the user is redirected to the dashboard, sign out, then sign back in with the same credentials and verify the dashboard loads again.

Playwright Implementation

supabase-auth.spec.ts

Assrt Equivalent

# scenarios/supabase-email-auth.assrt
describe: Email/password sign-in with Supabase

given:
  - a test user exists with email "e2e@test.local" and password "TestPassword123!"
  - I am on the login page

steps:
  - fill the email field with "e2e@test.local"
  - fill the password field with "TestPassword123!"
  - click "Sign in"

expect:
  - I am redirected to /dashboard within 10 seconds
  - the page shows a "Dashboard" heading

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: OAuth via Supabase (Google, GitHub)

3

OAuth Login Through Supabase

Complex

OAuth through Supabase involves three redirects: your app redirects to Supabase Auth, Supabase redirects to the OAuth provider (Google, GitHub, etc.), and the provider redirects back to Supabase, which then redirects to your app with session tokens. Testing this end-to-end against a real provider in CI is fragile because the provider login pages change frequently and require real credentials. The recommended approach is to test two things separately: that the initial redirect lands on the correct provider URL, and that the callback handler works when given a valid authorization code.

Testing the Redirect (Without Real Provider Credentials)

import { test, expect } from '@playwright/test';

test('GitHub OAuth redirects to correct authorize URL', async ({ page }) => {
  await page.goto('/auth/login');

  // Intercept navigation to capture the redirect URL
  const [request] = await Promise.all([
    page.waitForRequest(req =>
      req.url().includes('github.com/login/oauth/authorize')
    ),
    page.getByRole('button', { name: /continue with github/i }).click(),
  ]);

  const url = new URL(request.url());
  // Verify the OAuth params are correct
  expect(url.searchParams.get('client_id')).toBeTruthy();
  expect(url.searchParams.get('redirect_uri')).toContain(
    'supabase.co/auth/v1/callback'
  );
  expect(url.searchParams.get('scope')).toContain('user:email');
});

test('Google OAuth redirects to correct authorize URL', async ({ page }) => {
  await page.goto('/auth/login');

  const [request] = await Promise.all([
    page.waitForRequest(req =>
      req.url().includes('accounts.google.com/o/oauth2')
    ),
    page.getByRole('button', { name: /continue with google/i }).click(),
  ]);

  const url = new URL(request.url());
  expect(url.searchParams.get('client_id')).toBeTruthy();
  expect(url.searchParams.get('redirect_uri')).toContain(
    'supabase.co/auth/v1/callback'
  );
});

Testing the Callback Handler

To test the callback side without real provider credentials, use the Supabase Admin API to create a user with a specific provider identity, then simulate the session by setting the tokens directly. This avoids the fragile middle step of automating a third-party login page.

import { test, expect } from '@playwright/test';
import { createClient } from '@supabase/supabase-js';

test('OAuth callback sets session and redirects to dashboard', async ({ page }) => {
  const supabaseAdmin = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  // Create a user that mimics an OAuth sign-in
  const { data } = await supabaseAdmin.auth.admin.createUser({
    email: `oauth+${Date.now()}@test.local`,
    email_confirm: true,
    app_metadata: { provider: 'github', providers: ['github'] },
    user_metadata: { avatar_url: 'https://example.com/avatar.png' },
  });

  // Generate a session for this user
  const { data: session } = await supabaseAdmin.auth.admin
    .generateLink({ type: 'magiclink', email: data.user!.email! });

  // Navigate the callback URL with the token
  await page.goto(session.properties?.action_link || '/');
  await page.waitForURL(/\/dashboard/, { timeout: 15_000 });
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();
});

Assrt Equivalent

# scenarios/supabase-oauth.assrt
describe: GitHub OAuth redirect points to the correct provider

given:
  - I am on the login page

steps:
  - click "Continue with GitHub"

expect:
  - the browser navigates to github.com/login/oauth/authorize
  - the redirect_uri parameter contains "supabase.co/auth/v1/callback"
  - the client_id parameter is present

6. Scenario: Phone Auth with OTP

4

Phone OTP Authentication

Moderate

Supabase supports phone-based authentication where a one-time password is sent via SMS. In production this goes through Twilio or another SMS provider, but in the local development stack you can configure a test phone provider that logs OTP codes to the Supabase logs. For CI, the most reliable approach is to use the Supabase Admin API to verify the OTP directly, or to read the OTP from the auth schema in PostgreSQL.

Playwright Implementation

import { test, expect } from '@playwright/test';
import { createClient } from '@supabase/supabase-js';
import { Pool } from 'pg';

// Connect to local Supabase Postgres to read OTP
const pool = new Pool({
  connectionString: 'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
});

test('phone OTP sign-in', async ({ page }) => {
  const phone = '+15555550100';

  await page.goto('/auth/login');
  await page.getByRole('tab', { name: /phone/i }).click();
  await page.getByLabel('Phone number').fill(phone);
  await page.getByRole('button', { name: /send code/i }).click();

  await expect(page.getByText(/enter.*code/i)).toBeVisible();

  // Read the OTP directly from the local Supabase database
  const result = await pool.query(
    `SELECT phone_change_token FROM auth.users
     WHERE phone = $1
     ORDER BY updated_at DESC LIMIT 1`,
    [phone]
  );
  const otp = result.rows[0]?.phone_change_token || '000000';

  // Fill the OTP input
  await page.getByLabel('Verification code').fill(otp);
  await page.getByRole('button', { name: /verify/i }).click();

  await page.waitForURL(/\/dashboard/, { timeout: 10_000 });
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();
});

Note: The exact column name for the OTP depends on your Supabase version and configuration. In some versions the token is stored in confirmation_token rather than phone_change_token. Check the auth.users table schema in your local instance.

Assrt Equivalent

# scenarios/supabase-phone-otp.assrt
describe: Phone OTP login using local Supabase

given:
  - I am on the login page
  - I use the phone tab

steps:
  - fill the phone number field with "+15555550100"
  - click "Send code"
  - wait for the verification code input to appear
  - retrieve the OTP from the local database for "+15555550100"
  - fill the verification code field with the OTP
  - click "Verify"

expect:
  - I am redirected to /dashboard within 10 seconds
  - the page shows a "Dashboard" heading

7. Scenario: RLS-Guarded Data Access After Auth

5

Row Level Security Verification

Complex

Row Level Security is what makes Supabase auth testing fundamentally different from testing other auth providers. With RLS enabled, a signed-in user can only read and write rows where the auth.uid() matches the row owner. This means your auth test is incomplete if it only verifies "user can log in." You also need to verify that User A cannot see User B's data, and that an unauthenticated request returns nothing.

Playwright Implementation

supabase-auth.spec.ts

Assrt Equivalent

# scenarios/supabase-rls.assrt
describe: RLS prevents cross-user data access

given:
  - User A exists and has a todo titled "User A todo"
  - User B exists and has a todo titled "User B todo"
  - I am signed in as User A

steps:
  - navigate to /todos

expect:
  - I can see "User A todo"
  - I cannot see "User B todo"

8. Scenario: Token Refresh and Session Persistence

6

Token Refresh and Session Persistence

Moderate

Supabase access tokens have a short lifetime (default: 3600 seconds). The client library refreshes them automatically using the refresh token stored in local storage. If the refresh mechanism breaks, users get silently logged out after an hour. This is the kind of bug that never appears in manual testing because nobody sits on a page for an hour, but it will hit your users every day.

Playwright Implementation

import { test, expect } from '@playwright/test';
import { seedTestUser } from './helpers/seed-user';

test('session persists after page reload', async ({ page }) => {
  const email = `persist+${Date.now()}@test.local`;
  const password = 'TestPassword123!';
  await seedTestUser(email, password);

  // Sign in
  await page.goto('/auth/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: /sign in/i }).click();
  await page.waitForURL(/\/dashboard/);

  // Verify session tokens exist in local storage
  const accessToken = await page.evaluate(() =>
    localStorage.getItem('sb-127-auth-token')
  );
  expect(accessToken).toBeTruthy();

  // Reload the page and verify the user is still authenticated
  await page.reload();
  await expect(page.getByRole('heading', { name: /dashboard/i }))
    .toBeVisible();
  // Should NOT be redirected to login
  await expect(page).not.toHaveURL(/\/auth\/login/);
});

test('token refresh works after expiry simulation', async ({ page }) => {
  const email = `refresh+${Date.now()}@test.local`;
  const password = 'TestPassword123!';
  await seedTestUser(email, password);

  await page.goto('/auth/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: /sign in/i }).click();
  await page.waitForURL(/\/dashboard/);

  // Simulate token expiry by clearing only the access token
  // but leaving the refresh token intact
  await page.evaluate(() => {
    const stored = localStorage.getItem('sb-127-auth-token');
    if (stored) {
      const parsed = JSON.parse(stored);
      parsed.access_token = 'expired-token';
      parsed.expires_at = Math.floor(Date.now() / 1000) - 100;
      localStorage.setItem('sb-127-auth-token', JSON.stringify(parsed));
    }
  });

  // Trigger a navigation that requires auth
  await page.goto('/todos');

  // The Supabase client should auto-refresh using the refresh token
  // and the page should load without redirecting to login
  await expect(page.getByRole('heading', { name: /todos/i }))
    .toBeVisible({ timeout: 10_000 });
});

Assrt Equivalent

# scenarios/supabase-session-persistence.assrt
describe: Session persists across page reloads

given:
  - a test user exists
  - I am signed in to the dashboard

steps:
  - reload the page

expect:
  - I am still on /dashboard
  - I am NOT redirected to the login page
  - the dashboard heading is visible

9. Common Pitfalls

Using Inbucket Mailbox Names Incorrectly

Inbucket uses the local part of the email address (everything before the @) as the mailbox name. If you send an email to test+magic@test.local, the mailbox name is test+magic, not test+magic@test.local. Getting this wrong means your test will poll Inbucket forever and time out.

PKCE Code Verifier Mismatch

This is the single most common "it works manually but fails in tests" bug. Supabase stores the PKCE code verifier in the browser that initiated the auth flow. If your test opens the magic link or OAuth callback in a different browser context, tab, or incognito window, the code exchange will fail. Always open confirmation links in the same page object that initiated the flow, or extract and transfer the verifier manually.

Testing RLS with the Service Role Key

The service role key bypasses all RLS policies. If your test uses the service role key to query data, your RLS test will always pass, even if the policies are completely wrong. Use the anon key with a proper Authorization header containing the user's JWT to test RLS accurately. Reserve the service role key for setup and teardown only.

Forgetting to Wait for Email Delivery

Even in the local stack, email delivery from GoTrue to Inbucket is asynchronous. If your test queries Inbucket immediately after calling signInWithOtp, the email may not have arrived yet. Always poll with a retry loop and a timeout rather than making a single request.

Hardcoding the Local Storage Key

Supabase stores session data in local storage under a key that includes the project reference, such as sb-<project-ref>-auth-token. In the local stack, the project reference is derived from the port number. If you change your local Supabase configuration, the key changes. Instead of hardcoding the key, search local storage for keys matching the sb-*-auth-token pattern.

Not Cleaning Up Test Users

Each test run creates users in the local auth.users table. Over time this clutter can slow down your local Postgres and cause collisions in email addresses. Add a global teardown that deletes all users created during the test run, identified by a naming convention like the +e2e or +test suffix in the email address.

10. Writing These Scenarios in Plain English with Assrt

Each scenario above is 30 to 60 lines of Playwright TypeScript. Across six scenarios that is 200 to 350 lines of test code, all tightly coupled to DOM selectors, Inbucket API responses, and local storage key formats. When Supabase updates their auth UI or changes the local storage schema, you are updating every test by hand. Assrt lets you describe what you want to test in plain English and generates the Playwright code for you.

The magic link scenario from Section 4 is a good example of the difference. The Playwright version requires you to know about the Inbucket API, PKCE verifier persistence, and the exact structure of the confirmation URL. The Assrt version says "retrieve the magic link from Inbucket" and "open the magic link in the same browser," and Assrt handles the implementation details.

# scenarios/supabase-full-auth-suite.assrt
describe: Complete Supabase Auth test suite

---
scenario: Email/password sign-in
given:
  - a test user exists with email "e2e@test.local"
steps:
  - go to /auth/login
  - fill email and password
  - click "Sign in"
expect:
  - redirected to /dashboard

---
scenario: Magic link login
given:
  - I am on /auth/login
steps:
  - click "Magic link" tab
  - enter a fresh email
  - click "Send magic link"
  - retrieve the link from Inbucket
  - open it in the same browser
expect:
  - redirected to /dashboard
  - I am authenticated

---
scenario: RLS data isolation
given:
  - User A has a todo "Buy milk"
  - User B has a todo "Write tests"
  - I am signed in as User A
steps:
  - go to /todos
expect:
  - I see "Buy milk"
  - I do not see "Write tests"

---
scenario: Session persists after reload
given:
  - I am signed in to /dashboard
steps:
  - reload the page
expect:
  - still on /dashboard
  - not redirected to login

Assrt compiles each scenario block into a standalone Playwright test file, committed to your repository as real TypeScript you can read and run directly. When Supabase changes the auth UI, renames a local storage key, or updates the Inbucket API, Assrt detects the test failure, analyzes the new DOM and API responses, and opens a pull request with updated selectors and API calls. Your scenario files remain untouched.

Start with the email/password scenario. Once it passes in your CI pipeline, add magic link, then RLS verification, then session persistence, then OAuth redirects, then phone OTP. In a single afternoon you can ship the complete Supabase Auth test coverage that most teams never get around to writing by hand.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk