Comparing Auth Providers in Integration Tests: Clerk vs Auth0 vs Supabase Auth
Choosing an auth provider is a long-term architectural decision, and your test strategy should make switching providers feasible if your needs change. This guide compares how Clerk, Auth0, and Supabase Auth work in integration tests, and shows how to structure your tests so the auth provider is an implementation detail, not a hard dependency.
The Core Testing Challenge
Each auth provider has a different SDK, different mock utilities, and different mental models:
| Provider | Session validation | Mock utility | Webhook format | Self-hostable |
|---|---|---|---|---|
| Clerk | useAuth() hook + getAuth() server |
@clerk/testing |
Svix signed | No |
| Auth0 | JWT validation + useUser() |
@auth0/nextjs-auth0/testing |
Auth0 signed | No (Okta) |
| Supabase Auth | supabase.auth.getSession() |
@supabase/supabase-js mock |
Standard POST | Yes |
Abstracting Auth Behind an Interface
The key to provider-agnostic tests is wrapping auth behind your own interface:
// lib/auth-interface.ts
export interface AuthUser {
id: string;
email: string;
name: string | null;
role: 'admin' | 'user' | 'guest';
}
export interface AuthSession {
user: AuthUser;
expiresAt: Date;
}
export interface AuthProvider {
getSession(request?: Request): Promise<AuthSession | null>;
getUserById(userId: string): Promise<AuthUser | null>;
requireSession(request?: Request): Promise<AuthSession>; // throws if unauthenticated
}Each provider implements this interface:
// lib/auth-providers/clerk.ts
import { getAuth } from '@clerk/nextjs/server';
import type { AuthProvider, AuthSession } from '../auth-interface';
export class ClerkAuthProvider implements AuthProvider {
async getSession(request?: Request): Promise<AuthSession | null> {
const { userId } = request ? getAuth(request as any) : { userId: null };
if (!userId) return null;
// Fetch user from Clerk
const { clerkClient } = await import('@clerk/nextjs/server');
const user = await clerkClient.users.getUser(userId);
return {
user: {
id: userId,
email: user.primaryEmailAddress?.emailAddress || '',
name: `${user.firstName} ${user.lastName}`.trim(),
role: (user.publicMetadata.role as 'admin' | 'user') || 'user',
},
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
};
}
async getUserById(userId: string): Promise<AuthUser | null> {
// implementation
return null;
}
async requireSession(request?: Request): Promise<AuthSession> {
const session = await this.getSession(request);
if (!session) throw new Error('Unauthorized');
return session;
}
}Now your tests mock the interface, not the provider:
// test-utils/auth-mock.ts
import type { AuthProvider, AuthSession } from '../lib/auth-interface';
export function createMockAuthProvider(overrides: Partial<AuthProvider> = {}): AuthProvider {
return {
getSession: jest.fn().mockResolvedValue(null),
getUserById: jest.fn().mockResolvedValue(null),
requireSession: jest.fn().mockRejectedValue(new Error('Unauthorized')),
...overrides,
};
}
export function createAuthenticatedProvider(userOverrides = {}): AuthProvider {
const user = {
id: 'user-1',
email: 'jane@example.com',
name: 'Jane Smith',
role: 'user' as const,
...userOverrides,
};
const session: AuthSession = {
user,
expiresAt: new Date(Date.now() + 3600000),
};
return createMockAuthProvider({
getSession: jest.fn().mockResolvedValue(session),
requireSession: jest.fn().mockResolvedValue(session),
getUserById: jest.fn().mockResolvedValue(user),
});
}Comparing Provider-Specific Test Patterns
Clerk Integration Tests
import { clerkClient } from '@clerk/nextjs/server';
import { createClerkClient } from '@clerk/backend';
jest.mock('@clerk/nextjs/server', () => ({
getAuth: jest.fn(),
clerkClient: {
users: {
getUser: jest.fn(),
getUserList: jest.fn(),
},
},
authMiddleware: jest.fn(() => (req: Request) => req),
}));
describe('Clerk auth integration', () => {
test('extracts user role from Clerk metadata', async () => {
(clerkClient.users.getUser as jest.Mock).mockResolvedValue({
id: 'user_abc123',
primaryEmailAddress: { emailAddress: 'admin@example.com' },
firstName: 'Admin',
lastName: 'User',
publicMetadata: { role: 'admin' },
});
const provider = new ClerkAuthProvider();
const session = await provider.getSession(
new Request('http://localhost', {
headers: { Authorization: 'Bearer mock-token' },
})
);
expect(session?.user.role).toBe('admin');
});
});Auth0 Integration Tests
import { getSession } from '@auth0/nextjs-auth0';
jest.mock('@auth0/nextjs-auth0', () => ({
getSession: jest.fn(),
withApiAuthRequired: jest.fn(handler => handler),
withPageAuthRequired: jest.fn(page => page),
}));
describe('Auth0 auth integration', () => {
test('extracts user from Auth0 session', async () => {
(getSession as jest.Mock).mockResolvedValue({
user: {
sub: 'auth0|user123',
email: 'jane@example.com',
name: 'Jane Smith',
'https://myapp.com/role': 'admin', // Custom claim in namespace
},
});
const provider = new Auth0AuthProvider();
const session = await provider.getSession();
expect(session?.user.id).toBe('auth0|user123');
expect(session?.user.role).toBe('admin');
});
test('validates JWT token claims', async () => {
// Auth0 JWT validation happens in withApiAuthRequired
// Test the middleware wrapper
const handler = jest.fn().mockResolvedValue(new Response('OK'));
const wrappedHandler = withApiAuthRequired(handler);
(getSession as jest.Mock).mockResolvedValue(null); // No session
const response = await wrappedHandler(new Request('http://localhost'));
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
});
});Supabase Auth Integration Tests
import { createClient } from '@supabase/supabase-js';
jest.mock('@supabase/supabase-js', () => ({
createClient: jest.fn(() => ({
auth: {
getSession: jest.fn(),
getUser: jest.fn(),
signInWithPassword: jest.fn(),
signOut: jest.fn(),
},
})),
}));
describe('Supabase auth integration', () => {
let mockSupabase: ReturnType<typeof createClient>;
beforeEach(() => {
mockSupabase = createClient('https://test.supabase.co', 'test-anon-key');
});
test('retrieves session from Supabase JWT', async () => {
(mockSupabase.auth.getSession as jest.Mock).mockResolvedValue({
data: {
session: {
access_token: 'test-jwt',
user: {
id: 'uuid-user-123',
email: 'jane@example.com',
user_metadata: { full_name: 'Jane Smith' },
app_metadata: { role: 'admin' },
},
},
},
error: null,
});
const provider = new SupabaseAuthProvider(mockSupabase);
const session = await provider.getSession();
expect(session?.user.id).toBe('uuid-user-123');
expect(session?.user.role).toBe('admin');
});
test('returns null when no Supabase session exists', async () => {
(mockSupabase.auth.getSession as jest.Mock).mockResolvedValue({
data: { session: null },
error: null,
});
const provider = new SupabaseAuthProvider(mockSupabase);
const session = await provider.getSession();
expect(session).toBeNull();
});
});Webhook Testing Comparison
Each provider sends webhooks for user lifecycle events. Their formats differ:
Clerk Webhooks (Svix)
import { Webhook } from 'svix';
test('Clerk user.created webhook creates database record', async () => {
const webhookSecret = 'whsec_test123';
process.env.CLERK_WEBHOOK_SECRET = webhookSecret;
const mockVerify = jest.fn().mockReturnValue({
type: 'user.created',
data: {
id: 'user_clerk123',
email_addresses: [{ email_address: 'new@example.com', id: 'em1' }],
primary_email_address_id: 'em1',
first_name: 'New',
last_name: 'User',
},
});
jest.mock('svix', () => ({
Webhook: jest.fn(() => ({ verify: mockVerify })),
}));
const response = await webhookHandler(createWebhookRequest());
expect(response.status).toBe(200);
expect(mockCreateUser).toHaveBeenCalledWith({ email: 'new@example.com' });
});Auth0 Webhooks
test('Auth0 user event webhook syncs user to database', async () => {
// Auth0 uses a different webhook format
const payload = {
event_category: 'user',
event_type: 'ss-user-created',
user: {
user_id: 'auth0|user123',
email: 'new@example.com',
name: 'New User',
},
};
const response = await auth0WebhookHandler(
new Request('http://localhost/api/webhooks/auth0', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Auth0 uses Authorization header for webhook verification
'Authorization': `Bearer ${process.env.AUTH0_WEBHOOK_TOKEN}`,
},
body: JSON.stringify(payload),
})
);
expect(response.status).toBe(200);
});Supabase Auth Hooks
test('Supabase auth hook creates user profile', async () => {
// Supabase uses Database Webhooks or Auth Hooks
const payload = {
type: 'INSERT',
table: 'auth.users',
record: {
id: 'uuid-supabase-123',
email: 'new@example.com',
raw_user_meta_data: { full_name: 'New User' },
},
schema: 'auth',
};
const response = await supabaseHookHandler(
new Request('http://localhost/api/webhooks/supabase', {
method: 'POST',
body: JSON.stringify(payload),
})
);
expect(response.status).toBe(200);
expect(mockCreateProfile).toHaveBeenCalledWith({
userId: 'uuid-supabase-123',
email: 'new@example.com',
name: 'New User',
});
});Provider-Agnostic Test Patterns
Tests that use your auth interface instead of provider SDKs work with any provider:
import { createAuthenticatedProvider, createMockAuthProvider } from '../test-utils/auth-mock';
describe('Dashboard page (provider-agnostic)', () => {
test('renders for authenticated admin users', async () => {
const authProvider = createAuthenticatedProvider({ role: 'admin' });
// Inject the mock provider
render(<Dashboard authProvider={authProvider} />);
await waitFor(() => {
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});
});
test('shows limited view for regular users', async () => {
const authProvider = createAuthenticatedProvider({ role: 'user' });
render(<Dashboard authProvider={authProvider} />);
await waitFor(() => {
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
expect(screen.getByTestId('user-dashboard')).toBeInTheDocument();
});
});
test('redirects unauthenticated users', async () => {
const authProvider = createMockAuthProvider();
render(<Dashboard authProvider={authProvider} />);
expect(mockRouterReplace).toHaveBeenCalledWith('/sign-in');
});
});This test passes regardless of whether you're using Clerk, Auth0, or Supabase Auth behind the interface.
E2E Testing with HelpMeTest
Provider-specific auth flows need real browser testing. HelpMeTest saves auth state once and reuses it:
# Clerk
Save As ClerkUser
Go to https://your-app.com/sign-in
Enter credentials for Clerk
Verify dashboard loads
# Auth0
Save As Auth0User
Go to https://your-app.com/api/auth/login
Complete Auth0 universal login
Verify redirect back to app
# Supabase
Save As SupabaseUser
Go to https://your-app.com/auth/login
Enter email and password for Supabase auth
Verify email confirmation flow (if required)
Verify dashboard accessThen across all providers:
As <ProviderUser>
Go to https://your-app.com/dashboard
Verify same dashboard loads for all providers
Verify user profile shows correct dataHelpMeTest makes it practical to run the same E2E test suite against different auth providers in CI, ensuring your abstraction works correctly for all.
Summary
Comparing auth providers in integration tests reveals:
- Clerk — strongest mocking utilities (
@clerk/testing), but most vendor lock-in - Auth0 — mature testing support, JWT-centric, good for enterprise
- Supabase Auth — easiest to mock (plain SDK), self-hostable, row-level security integration
Best practices across all providers:
- Abstract auth behind an interface — your components should not import from
@clerk/nextjsdirectly - Write provider-agnostic tests — use your interface mock, not provider mocks
- Test webhooks thoroughly — each provider's webhook format is unique
- Use HelpMeTest for E2E — real OAuth flows and session persistence can only be verified in real browsers