Testing Supabase Auth: Email Flows, OAuth, JWT Claims, and RLS

Testing Supabase Auth: Email Flows, OAuth, JWT Claims, and RLS

Supabase Auth combines JWT issuance, session management, email confirmation, OAuth, and Row Level Security into a single system. Testing each layer in isolation — and then together — catches bugs that only appear at the boundaries. This guide covers unit testing with mocked supabase-js, integration testing against a local Supabase instance, JWT claim assertions, OAuth flow strategies, and RLS-coupled auth tests using the service role.

Key Takeaways

Use the service role only in test setup/teardown. Creating and deleting test users requires service_role key access. Never expose this in client-side test code — keep it in your test helpers and .env.test.

Mock supabase-js at the module boundary, not deep inside. Create a __mocks__/supabase.ts that returns controlled auth state. Tests that mock onAuthStateChange at the listener level are fragile; mock the factory instead.

JWT claims are testable without a live auth server. Decode the JWT your test session returns with jose or jsonwebtoken and assert on role, app_metadata, and custom claims directly in your test suite.

RLS integration tests need two clients. Use a service-role admin client for data setup, then switch to an anon or user client to assert what each role can and cannot read. This pattern surfaces RLS policy bugs that unit tests miss entirely.

Refresh token handling has timing dependencies. Test token refresh by manipulating expires_at in the session object or by mocking Date.now() — not by actually waiting for tokens to expire.

Supabase Auth is deceptively testable. The supabase-js client exposes clean method surfaces (signUp, signInWithPassword, signOut, onAuthStateChange), the JWTs it issues are standard RS256 tokens you can decode anywhere, and the local dev stack (supabase start) gives you a fully functional auth server in Docker. The challenge is not lack of seams — it is knowing which layer to test at which level.

This guide works through each layer: unit tests with a mocked client, integration tests against local Supabase, JWT claim assertions, OAuth flow strategies, and the critical pattern of testing auth together with Row Level Security.

Setting Up the Test Environment

Install your test dependencies:

npm install -D vitest @vitest/coverage-v8 @supabase/supabase-js
# For JWT inspection
npm install -D jose
<span class="hljs-comment"># Optional: for OAuth PKCE flow testing
npm install -D @supabase/auth-helpers-nextjs

Create .env.test at the project root. The local Supabase CLI exposes predictable defaults:

SUPABASE_URL=http://localhost:54321
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...  # from `supabase status`
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Create two clients in your test helpers — one for user actions, one for admin setup:

// tests/helpers/supabase.ts
import { createClient, SupabaseClient } from '@supabase/supabase-js'

export function createAnonClient(): SupabaseClient {
  return createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!
  )
}

export function createAdminClient(): SupabaseClient {
  return createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { autoRefreshToken: false, persistSession: false } }
  )
}

export async function createTestUser(
  email: string,
  password: string,
  metadata: Record<string, unknown> = {}
): Promise<string> {
  const admin = createAdminClient()
  const { data, error } = await admin.auth.admin.createUser({
    email,
    password,
    email_confirm: true, // skip email confirmation in tests
    user_metadata: metadata,
  })
  if (error) throw new Error(`createTestUser failed: ${error.message}`)
  return data.user.id
}

export async function deleteTestUser(userId: string): Promise<void> {
  const admin = createAdminClient()
  await admin.auth.admin.deleteUser(userId)
}

The email_confirm: true flag on admin.createUser is essential — it bypasses the email confirmation flow so your tests do not depend on an SMTP server or inbox polling.

Testing Sign-Up and Sign-In Flows

Integration tests for the happy path:

// tests/auth/sign-in.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createAnonClient, createTestUser, deleteTestUser } from '../helpers/supabase'

describe('email/password auth', () => {
  const email = `test-${Date.now()}@example.com`
  const password = 'SecureTestPass123!'
  let userId: string

  beforeEach(async () => {
    userId = await createTestUser(email, password)
  })

  afterEach(async () => {
    await deleteTestUser(userId)
  })

  it('signs in with correct credentials and returns a session', async () => {
    const client = createAnonClient()
    const { data, error } = await client.auth.signInWithPassword({ email, password })

    expect(error).toBeNull()
    expect(data.session).not.toBeNull()
    expect(data.session!.access_token).toBeTruthy()
    expect(data.user!.email).toBe(email)
  })

  it('rejects incorrect password with AuthApiError', async () => {
    const client = createAnonClient()
    const { data, error } = await client.auth.signInWithPassword({
      email,
      password: 'wrong-password',
    })

    expect(data.session).toBeNull()
    expect(error).not.toBeNull()
    expect(error!.message).toMatch(/invalid login credentials/i)
  })

  it('rejects unknown email', async () => {
    const client = createAnonClient()
    const { error } = await client.auth.signInWithPassword({
      email: 'nobody@example.com',
      password,
    })

    expect(error).not.toBeNull()
    expect(error!.status).toBe(400)
  })

  it('signs out and clears the session', async () => {
    const client = createAnonClient()
    await client.auth.signInWithPassword({ email, password })

    const { error } = await client.auth.signOut()
    expect(error).toBeNull()

    const { data: { session } } = await client.auth.getSession()
    expect(session).toBeNull()
  })
})

Unit Testing with a Mocked supabase-js Client

For unit tests that should not hit a real server, mock the entire Supabase client at the module boundary:

// tests/__mocks__/supabase.ts
import { vi } from 'vitest'

export const mockSignIn = vi.fn()
export const mockSignOut = vi.fn()
export const mockGetSession = vi.fn()
export const mockOnAuthStateChange = vi.fn()

export const supabase = {
  auth: {
    signInWithPassword: mockSignIn,
    signOut: mockSignOut,
    getSession: mockGetSession,
    onAuthStateChange: mockOnAuthStateChange,
  },
}

export const createClient = vi.fn(() => supabase)

A unit test for a login form component that uses this mock:

// tests/components/LoginForm.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mockSignIn, mockGetSession } from '../__mocks__/supabase'

vi.mock('@/lib/supabase', () => import('../__mocks__/supabase'))

// import the component or hook AFTER the mock is registered
import { useAuth } from '@/hooks/useAuth'

describe('useAuth hook', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('returns authenticated state after successful sign-in', async () => {
    mockSignIn.mockResolvedValueOnce({
      data: {
        session: { access_token: 'tok_test', user: { id: 'uid-1', email: 'u@test.com' } },
        user: { id: 'uid-1', email: 'u@test.com' },
      },
      error: null,
    })

    const { signIn, user, error } = useAuth()
    await signIn('u@test.com', 'password')

    expect(mockSignIn).toHaveBeenCalledWith({ email: 'u@test.com', password: 'password' })
    expect(user?.email).toBe('u@test.com')
    expect(error).toBeNull()
  })

  it('exposes the error on failed sign-in', async () => {
    mockSignIn.mockResolvedValueOnce({
      data: { session: null, user: null },
      error: { message: 'Invalid login credentials', status: 400 },
    })

    const { signIn, error } = useAuth()
    await signIn('u@test.com', 'wrong')

    expect(error?.message).toMatch(/invalid login credentials/i)
  })
})

Testing JWT Claims and Custom Metadata

When you decode the JWT issued by Supabase, you get the full claim set including role, aud, sub, app_metadata, and any custom claims set via database hooks or edge functions.

// tests/auth/jwt-claims.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { decodeJwt } from 'jose'
import { createAnonClient, createAdminClient, createTestUser, deleteTestUser } from '../helpers/supabase'

describe('JWT claims', () => {
  let userId: string
  const email = `jwt-test-${Date.now()}@example.com`
  const password = 'TestPass123!'

  beforeEach(async () => {
    userId = await createTestUser(email, password, { plan: 'pro', org_id: 'org-42' })
  })

  afterEach(async () => {
    await deleteTestUser(userId)
  })

  it('JWT contains standard Supabase claims', async () => {
    const client = createAnonClient()
    const { data } = await client.auth.signInWithPassword({ email, password })
    const token = data.session!.access_token

    const claims = decodeJwt(token)

    expect(claims.iss).toContain('supabase')
    expect(claims.sub).toBe(userId)
    expect(claims.role).toBe('authenticated')
    expect(claims.aud).toBe('authenticated')
    expect(typeof claims.exp).toBe('number')
    expect(typeof claims.iat).toBe('number')
  })

  it('user_metadata is accessible in the JWT', async () => {
    const client = createAnonClient()
    const { data } = await client.auth.signInWithPassword({ email, password })
    const claims = decodeJwt(data.session!.access_token)

    expect((claims as any).user_metadata?.plan).toBe('pro')
    expect((claims as any).user_metadata?.org_id).toBe('org-42')
  })

  it('service role JWT has role=service_role', async () => {
    const admin = createAdminClient()
    // The admin client's internal JWT carries service_role
    const { data } = await admin.auth.admin.getUserById(userId)
    // Confirm admin can access user data that the anon client cannot
    expect(data.user?.id).toBe(userId)
    expect(data.user?.email).toBe(email)
  })
})

If your app uses custom JWT claims via a PostgreSQL hook (auth.users → trigger → app_metadata update), test that the hook fires by updating app_metadata through the admin client and then refreshing the session:

it('refreshed JWT reflects updated app_metadata', async () => {
  const admin = createAdminClient()
  const client = createAnonClient()

  await client.auth.signInWithPassword({ email, password })

  // Update app_metadata as an admin action (e.g., after a subscription event)
  await admin.auth.admin.updateUserById(userId, {
    app_metadata: { subscription: 'enterprise' },
  })

  // Force a token refresh
  const { data } = await client.auth.refreshSession()
  const claims = decodeJwt(data.session!.access_token)

  expect((claims as any).app_metadata?.subscription).toBe('enterprise')
})

OAuth Flow Testing Strategies

OAuth flows involve external providers (Google, GitHub) and browser redirects — you cannot fully integration-test them without a real browser and real provider credentials. Break the testing into three layers:

1. Unit test the PKCE URL generation:

// tests/auth/oauth-url.test.ts
import { describe, it, expect } from 'vitest'
import { createAnonClient } from '../helpers/supabase'

it('generates a valid OAuth URL with PKCE code_challenge', async () => {
  const client = createAnonClient()
  const { data, error } = await client.auth.signInWithOAuth({
    provider: 'github',
    options: {
      redirectTo: 'http://localhost:3000/auth/callback',
      skipBrowserRedirect: true, // return URL instead of navigating
    },
  })

  expect(error).toBeNull()
  expect(data.url).toContain('github.com')
  expect(data.url).toContain('code_challenge')
  expect(data.url).toContain('code_challenge_method=S256')
  expect(data.url).toContain('redirect_uri=')
})

2. Test the callback handler with a mocked code exchange:

// tests/auth/oauth-callback.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mockGetSession } from '../__mocks__/supabase'

vi.mock('@/lib/supabase', () => import('../__mocks__/supabase'))

import { handleOAuthCallback } from '@/lib/auth'

it('handleOAuthCallback stores session from code exchange', async () => {
  mockGetSession.mockResolvedValueOnce({
    data: { session: { access_token: 'tok', user: { id: 'uid-oauth' } } },
    error: null,
  })

  const result = await handleOAuthCallback('code_from_provider')
  expect(result.userId).toBe('uid-oauth')
})

3. E2E test the full flow with a real browser. Use Playwright and a test OAuth provider account (create a dedicated GitHub/Google account for CI). This is the only layer that can verify the provider redirect, cookie setting, and session persistence across page loads.

Testing Auth and RLS Together

This is where bugs hide. RLS policies evaluate the JWT's sub and role claims — errors in either your policies or your auth flow only surface when you combine both. The pattern: use the admin client to insert data, then assert visibility through the anon client.

// tests/auth/rls-integration.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createAnonClient, createAdminClient, createTestUser, deleteTestUser } from '../helpers/supabase'

describe('profiles table RLS', () => {
  let userAId: string
  let userBId: string
  const emailA = `rls-a-${Date.now()}@example.com`
  const emailB = `rls-b-${Date.now()}@example.com`
  const password = 'TestPass123!'

  beforeEach(async () => {
    userAId = await createTestUser(emailA, password)
    userBId = await createTestUser(emailB, password)

    const admin = createAdminClient()
    // Insert profiles using admin (bypasses RLS)
    await admin.from('profiles').insert([
      { id: userAId, display_name: 'User A' },
      { id: userBId, display_name: 'User B' },
    ])
  })

  afterEach(async () => {
    const admin = createAdminClient()
    await admin.from('profiles').delete().in('id', [userAId, userBId])
    await deleteTestUser(userAId)
    await deleteTestUser(userBId)
  })

  it('authenticated user can read their own profile', async () => {
    const client = createAnonClient()
    await client.auth.signInWithPassword({ email: emailA, password })

    const { data, error } = await client.from('profiles').select('*').eq('id', userAId)

    expect(error).toBeNull()
    expect(data).toHaveLength(1)
    expect(data![0].display_name).toBe('User A')
  })

  it('authenticated user cannot read another user profile', async () => {
    const client = createAnonClient()
    await client.auth.signInWithPassword({ email: emailA, password })

    const { data, error } = await client.from('profiles').select('*').eq('id', userBId)

    // RLS returns empty array, not an error, for unauthorized reads
    expect(error).toBeNull()
    expect(data).toHaveLength(0)
  })

  it('unauthenticated client cannot read any profiles', async () => {
    const client = createAnonClient() // no sign-in
    const { data } = await client.from('profiles').select('*')

    expect(data).toHaveLength(0)
  })
})

Testing Session Persistence and Refresh Token Handling

Session persistence depends on the storage adapter (localStorage in browsers, custom stores in server environments). Test refresh token handling by manipulating the session directly:

// tests/auth/session-refresh.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createAnonClient, createTestUser, deleteTestUser } from '../helpers/supabase'

describe('session refresh', () => {
  const email = `refresh-${Date.now()}@example.com`
  const password = 'TestPass123!'
  let userId: string

  beforeEach(async () => {
    userId = await createTestUser(email, password)
  })

  afterEach(async () => {
    await deleteTestUser(userId)
  })

  it('refreshSession returns a new access token', async () => {
    const client = createAnonClient()
    const { data: initial } = await client.auth.signInWithPassword({ email, password })
    const originalToken = initial.session!.access_token

    // Small delay to ensure token iat differs
    await new Promise((r) => setTimeout(r, 1000))

    const { data: refreshed, error } = await client.auth.refreshSession()

    expect(error).toBeNull()
    expect(refreshed.session).not.toBeNull()
    // The new token should differ from the original
    expect(refreshed.session!.access_token).not.toBe(originalToken)
  })

  it('setSession with expired access_token triggers refresh on next call', async () => {
    const client = createAnonClient()
    const { data } = await client.auth.signInWithPassword({ email, password })

    // Simulate an expired session by setting expires_at to the past
    const expiredSession = {
      ...data.session!,
      expires_at: Math.floor(Date.now() / 1000) - 3600,
    }
    await client.auth.setSession({
      access_token: expiredSession.access_token,
      refresh_token: expiredSession.refresh_token!,
    })

    // getSession triggers a refresh when expires_at is in the past
    const { data: result } = await client.auth.getSession()
    expect(result.session).not.toBeNull()
  })
})

Running Auth Tests in CI

A minimal Vitest config that works with local Supabase:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',
    envFiles: ['.env.test'],
    // Run auth integration tests serially to avoid user creation races
    pool: 'forks',
    poolOptions: { forks: { singleFork: true } },
    testTimeout: 15000, // auth calls against local Docker can be slow on first run
  },
})

In your CI pipeline, start the local Supabase stack before tests run:

# .github/workflows/test.yml
- name: Start Supabase
  run: supabase start

- name: Run auth tests
  run: npx vitest run tests/auth

- name: Stop Supabase
  if: always()
  run: supabase stop

The supabase start step takes 30–60 seconds on a cold Docker cache. Cache the Docker images between CI runs using the docker layer cache to keep this practical.

What to Automate Beyond Unit Tests

The code examples above cover the logic layer. But auth bugs routinely surface at the UI level — the sign-in form submitting to the wrong endpoint, the OAuth redirect not setting the cookie correctly, the session not persisting after a hard reload. These need browser-level automation.

HelpMeTest runs these E2E auth scenarios continuously: sign-in happy path, OAuth callback, session persistence across tabs, and RLS-guarded page access — using Robot Framework and Playwright against your staging environment. When an auth regression appears at 2am after a Supabase version bump, the health monitor catches it before your users do. Pricing starts at $100/month for full-coverage monitoring with no test maintenance overhead on your team.

The combination of the Vitest integration tests in this guide plus continuous E2E monitoring gives you defense in depth: fast feedback in your dev loop, and production confidence from a live environment that runs the real flows your users hit.

Read more