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.
“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.”
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
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
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:3000Inbucket 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.
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
Email/Password Sign-Up and Sign-In
StraightforwardThis 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
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" heading4. Scenario: Magic Link Login with Inbucket
Magic Link Login via Inbucket Email Capture
ModerateMagic link login is the scenario most teams skip because they do not know how to intercept the email. With the local Supabase stack, every email routes to Inbucket, which exposes a REST API for programmatic retrieval. The test flow is: request the magic link, poll Inbucket for the email, extract the confirmation URL, navigate to it, and verify the user is authenticated.
Key Challenge: PKCE Code Verifier
Supabase uses PKCE for magic link flows. When the browser requests the magic link, the Supabase client stores a code_verifier in local storage. When the user clicks the magic link, the callback page reads that verifier to complete the token exchange. If your test opens the magic link in a new browser context (or a different page without the stored verifier), the exchange will fail with a "code challenge does not match" error. You must open the magic link in the same browser context that requested it.
Playwright vs Assrt
Magic Link Login: Playwright vs Assrt
import { test, expect } from '@playwright/test';
import { extractConfirmationUrl } from './helpers/inbucket';
test('magic link login via Inbucket', async ({ page }) => {
const mailbox = `magiclink+${Date.now()}`;
const email = `${mailbox}@test.local`;
// 1. Navigate to login and request a magic link
await page.goto('/auth/login');
await page.getByRole('tab', { name: /magic link/i }).click();
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: /send magic link/i }).click();
// 2. Wait for confirmation message in UI
await expect(page.getByText(/check your email/i)).toBeVisible();
// 3. Poll Inbucket for the email (retry because delivery is async)
let confirmUrl: string = '';
await expect(async () => {
confirmUrl = await extractConfirmationUrl(mailbox);
expect(confirmUrl).toBeTruthy();
}).toPass({ timeout: 10_000 });
// 4. Open the magic link in the SAME page (preserves PKCE verifier)
await page.goto(confirmUrl);
// 5. Supabase processes the token exchange and redirects
await page.waitForURL(/\/dashboard/, { timeout: 15_000 });
await expect(page.getByRole('heading', { name: /dashboard/i }))
.toBeVisible();
});5. Scenario: OAuth via Supabase (Google, GitHub)
OAuth Login Through Supabase
ComplexOAuth 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 present6. Scenario: Phone Auth with OTP
Phone OTP Authentication
ModerateSupabase 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" heading7. Scenario: RLS-Guarded Data Access After Auth
Row Level Security Verification
ComplexRow 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
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
Token Refresh and Session Persistence
ModerateSupabase 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 visible9. 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 loginAssrt 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
How to Test Auth0 Universal Login
A practical, scenario-by-scenario guide to testing Auth0 Universal Login with Playwright....
How to Test Azure AD Login
A practical, scenario-by-scenario guide to testing Azure AD (Entra ID) login with...
How to Test Clerk Sign-In
A practical, scenario-by-scenario guide to testing Clerk authentication with Playwright....
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.