diff --git a/public/blog/authentication-supabase-oauth.md b/public/blog/authentication-supabase-oauth.md index ba5ab31f..903c24cb 100644 --- a/public/blog/authentication-supabase-oauth.md +++ b/public/blog/authentication-supabase-oauth.md @@ -54,7 +54,7 @@ Here's what ships in our authentication system: ### 🛡️ Security Hardening - 🚦 **Server-Side Rate Limiting**: 5 failed attempts per 15-minute window, enforced in PostgreSQL (client can't bypass) -- 🔒 **OAuth CSRF Protection**: State token validation prevents session hijacking +- 🔒 **OAuth CSRF Protection**: Supabase's built-in OAuth2 `state` parameter prevents session hijacking - 📝 **Audit Logging**: Every authentication event logged to database with Internet Protocol (IP) address and user agent - 🗄️ **Row-Level Security**: Database policies ensure users only see their own data @@ -265,130 +265,27 @@ This callback handles both email verification and OAuth redirects (which we'll c Password fatigue is real. Users reuse passwords across sites, creating security nightmares. OAuth lets users authenticate with providers they already trust (GitHub, Google) without creating another password. -### OAuth Flow with CSRF Protection +### 🔒 OAuth Flow with CSRF Protection OAuth has a critical vulnerability: Cross-Site Request Forgery (CSRF) attacks. An attacker can initiate an OAuth flow and trick a victim into completing it, linking the attacker's GitHub account to the victim's app account. -We prevent this with **state tokens**: - -```typescript -// src/lib/auth/oauth-state.ts -import { supabase } from '@/lib/supabase/client'; - -// Generate UUID v4 using crypto API (available in modern browsers and Node 16+) -function generateUUID(): string { - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return crypto.randomUUID(); - } - // Fallback for older environments - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -/** - * Get or create a session ID for CSRF validation - * Uses sessionStorage to track the browser session - */ -function getSessionId(): string { - if (typeof window === 'undefined') { - return ''; - } - - const SESSION_KEY = 'oauth_session_id'; - let sessionId = sessionStorage.getItem(SESSION_KEY); - - if (!sessionId) { - sessionId = generateUUID(); - sessionStorage.setItem(SESSION_KEY, sessionId); - } - - return sessionId; -} - -/** - * Generate a cryptographically random state token for OAuth flow - * Stored in database with 5-minute expiration - */ -export async function generateOAuthState( - provider: 'github' | 'google' -): Promise { - const stateToken = generateUUID(); // Cryptographically random UUID - const sessionId = getSessionId(); // Get or create browser session ID - - // Store in database - const { error } = await supabase.from('oauth_states').insert({ - state_token: stateToken, - provider, - session_id: sessionId, // Tie to browser session - expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes - }); - - if (error) { - throw new Error('Failed to generate OAuth state'); - } - - return stateToken; -} - -/** - * Validate state token from OAuth callback - * Ensures the request originated from the same browser session - */ -export async function validateOAuthState(stateToken: string) { - const { data, error } = await supabase - .from('oauth_states') - .select('*') - .eq('state_token', stateToken) - .eq('used', false) // Prevent replay attacks - .single(); - - if (error || !data) { - return { valid: false, error: 'invalid_state' }; - } - - // Check expiration - if (new Date(data.expires_at) < new Date()) { - return { valid: false, error: 'state_expired' }; - } - - // Mark as used (single-use tokens) - await supabase - .from('oauth_states') - .update({ used: true }) - .eq('state_token', stateToken); - - return { valid: true, provider: data.provider }; -} -``` - -### OAuth Button Component - -Here's how we initiate OAuth flows with state token protection: +💡 **The good news**: with Supabase you don't hand-roll CSRF protection. The Supabase client automatically generates a cryptographically random OAuth2 `state` parameter, ties it to the browser, and verifies it when the provider redirects back — rejecting any mismatch. That is the standard, provider-recommended OAuth CSRF defense, so a self-managed `state`-token table only duplicates (more weakly) what Supabase already does. So the entire OAuth entry point is just: ```tsx // src/components/auth/OAuthButtons/OAuthButtons.tsx import { supabase } from '@/lib/supabase/client'; -import { generateOAuthState } from '@/lib/auth/oauth-state'; export function OAuthButtons() { const handleOAuth = async (provider: 'github' | 'google') => { try { - // Generate CSRF protection state token - const stateToken = await generateOAuthState(provider); - - // Initiate OAuth flow with state parameter - const { data, error } = await supabase.auth.signInWithOAuth({ + // Supabase handles CSRF protection via its built-in OAuth2 state + // parameter — no need to manually manage state tokens. + const { error } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/auth/callback`, scopes: provider === 'github' ? 'read:user user:email' : 'email profile', - queryParams: { - state: stateToken, // Include state for CSRF protection - }, }, }); @@ -417,45 +314,38 @@ export function OAuthButtons() { } ``` +⚠️ **Gotcha**: because ScriptHammer is a **static export** (no server to run a code exchange), the client is configured with `flowType: 'implicit'` (see `src/lib/supabase/client.ts`) — the provider returns the session token in the URL fragment and supabase-js picks it up on the callback. The `state` CSRF check is automatic either way; just register your callback URL in the Supabase dashboard's redirect allow-list. (A server-rendered app would instead use `flowType: 'pkce'` + `exchangeCodeForSession`.) + ### OAuth Callback Handling -When the user authorizes on GitHub/Google, they're redirected back to our callback with an authorization code. We validate the state token before exchanging the code for a session: +When the user authorizes on GitHub/Google, they're redirected back to our callback. Under the implicit flow, supabase-js reads the session token from the URL fragment and validates the `state` parameter internally — so the callback doesn't hand-check anything; it just waits for the client to reflect the authenticated session: ```tsx -// src/app/auth/callback/page.tsx (extended) -import { validateOAuthState } from '@/lib/auth/oauth-state'; - -export default async function AuthCallbackPage({ - searchParams, -}: { - searchParams: { code?: string; state?: string }; -}) { - const supabase = await createClient(); - - if (searchParams.code) { - // Validate OAuth state token (CSRF protection) - if (searchParams.state) { - const stateValidation = await validateOAuthState(searchParams.state); - - if (!stateValidation.valid) { - return redirect('/sign-in?error=oauth_security_error'); - } - } +// src/app/auth/callback/page.tsx (simplified) +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/hooks/useAuth'; - // Exchange authorization code for session - const { error } = await supabase.auth.exchangeCodeForSession( - searchParams.code - ); +export default function AuthCallbackPage() { + const { user, isLoading } = useAuth(); + const router = useRouter(); - if (error) { - return redirect('/sign-in?error=oauth_failed'); + useEffect(() => { + if (isLoading) return; + + // Supabase handles state validation internally — no manual check needed. + // supabase-js parses the token from the URL fragment and fires the auth + // state change; we just redirect once the session is present. + if (user) { + // ...populate the OAuth profile (non-blocking), then: + router.replace('/profile'); + } else { + router.replace('/sign-in?error=oauth_failed'); } + }, [user, isLoading, router]); - // Authenticated - redirect to dashboard - return redirect('/profile'); - } - - return redirect('/sign-in'); + return

Completing sign-in…

; } ``` @@ -922,12 +812,6 @@ beforeEach(async () => { .from('rate_limit_attempts') .delete() .eq('identifier', testEmail); - - // Clean up OAuth states - await supabase - .from('oauth_states') - .delete() - .neq('id', '00000000-0000-0000-0000-000000000000'); }); ``` diff --git a/scripts/reset-database.ts b/scripts/reset-database.ts index 3f461405..379112dd 100644 --- a/scripts/reset-database.ts +++ b/scripts/reset-database.ts @@ -271,7 +271,6 @@ async function main() { console.log('Step 3/5: Deleting user data...'); await deleteAllFromTable('auth_audit_logs', 'audit logs'); await deleteAllFromTable('rate_limit_attempts', 'rate limit attempts'); - await deleteAllFromTable('oauth_states', 'OAuth states'); // Delete user profiles except admin console.log(' 🗑️ Deleting user profiles (except admin)...'); diff --git a/src/components/auth/OAuthButtons/OAuthButtons.tsx b/src/components/auth/OAuthButtons/OAuthButtons.tsx index 2832846c..7740b8b1 100644 --- a/src/components/auth/OAuthButtons/OAuthButtons.tsx +++ b/src/components/auth/OAuthButtons/OAuthButtons.tsx @@ -26,8 +26,9 @@ export default function OAuthButtons({ className = '' }: OAuthButtonsProps) { setLoading(provider); try { - // Supabase handles CSRF protection via built-in state parameter (PKCE flow) - // No need to manually manage state tokens + // Supabase handles CSRF protection via its built-in OAuth2 state + // parameter (the client uses flowType: 'implicit' for this static export). + // No need to manually manage state tokens. await supabase.auth.signInWithOAuth({ provider, options: { diff --git a/src/lib/auth/__tests__/oauth-state.test.ts b/src/lib/auth/__tests__/oauth-state.test.ts deleted file mode 100644 index 5a5ad15e..00000000 --- a/src/lib/auth/__tests__/oauth-state.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -// Security Hardening: OAuth State Management Tests -// Feature 017 - Task T010 -// Purpose: Test OAuth CSRF protection via state parameter - -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -// In-memory store to simulate oauth_states table -const mockOAuthStates = new Map< - string, - { - id: string; - state_token: string; - provider: string; - session_id: string; - return_url: string; - used: boolean; - expires_at: Date; - created_at: Date; - } ->(); - -// Mock Supabase client -vi.mock('@/lib/supabase/client', () => ({ - supabase: { - from: (table: string) => { - if (table !== 'oauth_states') { - throw new Error(`Unexpected table: ${table}`); - } - return { - insert: (data: any) => ({ - then: (resolve: any) => { - const id = crypto.randomUUID(); - mockOAuthStates.set(data.state_token, { - id, - ...data, - used: false, - expires_at: new Date(Date.now() + 5 * 60 * 1000), - created_at: new Date(), - }); - resolve({ error: null }); - }, - }), - select: (columns: string) => ({ - eq: (field: string, value: string) => ({ - single: () => ({ - then: (resolve: any) => { - const state = mockOAuthStates.get(value); - if (state) { - resolve({ data: state, error: null }); - } else { - resolve({ data: null, error: { code: 'PGRST116' } }); - } - }, - }), - }), - lt: (field: string, value: string) => ({ - select: () => ({ - then: (resolve: any) => { - const now = new Date(value); - const expired: string[] = []; - mockOAuthStates.forEach((state, token) => { - if (state.expires_at < now) { - expired.push(token); - } - }); - expired.forEach((token) => mockOAuthStates.delete(token)); - resolve({ - data: expired.map((t) => ({ state_token: t })), - error: null, - }); - }, - }), - }), - }), - update: (data: any) => ({ - eq: (field: string, value: string) => ({ - then: (resolve: any) => { - const state = mockOAuthStates.get(value); - if (state) { - Object.assign(state, data); - } - resolve({ error: null }); - }, - }), - }), - delete: () => ({ - lt: (field: string, value: string) => ({ - select: () => ({ - then: (resolve: any) => { - const now = new Date(value); - const deleted: string[] = []; - mockOAuthStates.forEach((state, token) => { - if (state.expires_at < now) { - deleted.push(token); - } - }); - deleted.forEach((token) => mockOAuthStates.delete(token)); - resolve({ - data: deleted.map((t) => ({ state_token: t })), - error: null, - }); - }, - }), - }), - }), - }; - }, - }, -})); - -// Import after mocking -import { - generateOAuthState, - validateOAuthState, - cleanupExpiredStates, -} from '../oauth-state'; - -describe('OAuth State Management - CSRF Protection', () => { - beforeEach(() => { - // Clear in-memory state store - mockOAuthStates.clear(); - vi.clearAllMocks(); - }); - - describe('generateOAuthState', () => { - it('should generate a unique state token', async () => { - const state1 = await generateOAuthState('github'); - const state2 = await generateOAuthState('github'); - - expect(state1).toBeDefined(); - expect(state2).toBeDefined(); - expect(state1).not.toBe(state2); // Each should be unique - }); - - it('should store state token in database', async () => { - const stateToken = await generateOAuthState('github'); - - // Verify token was stored (would query database in actual implementation) - expect(stateToken).toMatch(/^[0-9a-f-]{36}$/i); // UUID format - }); - - it('should associate state with provider', async () => { - const githubState = await generateOAuthState('github'); - const googleState = await generateOAuthState('google'); - - expect(githubState).toBeTruthy(); - expect(googleState).toBeTruthy(); - expect(githubState).not.toBe(googleState); - }); - - it('should set expiration to 5 minutes', async () => { - const stateToken = await generateOAuthState('github'); - - // Verify expiration was set correctly - // This would check database in actual implementation - expect(stateToken).toBeDefined(); - }); - - it('should capture session ID for validation', async () => { - const stateToken = await generateOAuthState('github'); - - // Session ID should be captured from browser - expect(stateToken).toBeDefined(); - }); - - it('should capture IP address and user agent', async () => { - const stateToken = await generateOAuthState('github'); - - // IP and user agent stored for audit trail - expect(stateToken).toBeDefined(); - }); - }); - - describe('validateOAuthState', () => { - it('should validate a valid, unused state token', async () => { - const stateToken = await generateOAuthState('github'); - - const result = await validateOAuthState(stateToken); - - expect(result.valid).toBe(true); - expect(result.provider).toBe('github'); - }); - - it('should reject an already-used state token', async () => { - const stateToken = await generateOAuthState('github'); - - // First use - should succeed - await validateOAuthState(stateToken); - - // Second use - should fail (single-use tokens) - const result = await validateOAuthState(stateToken); - - expect(result.valid).toBe(false); - expect(result.error).toBe('state_already_used'); - }); - - it('should reject an expired state token', async () => { - // Generate state and artificially expire it - const stateToken = await generateOAuthState('github'); - - // In actual implementation, would update database to set expires_at in the past - // For now, test the concept - - // Attempt to validate expired token - // const result = await validateOAuthState(stateToken); - // expect(result.valid).toBe(false); - // expect(result.error).toBe('state_expired'); - - expect(stateToken).toBeDefined(); // Placeholder until implementation - }); - - it('should reject a non-existent state token', async () => { - const fakeToken = '00000000-0000-0000-0000-000000000000'; - - const result = await validateOAuthState(fakeToken); - - expect(result.valid).toBe(false); - expect(result.error).toBe('state_not_found'); - }); - - it('should reject if session ID mismatch', async () => { - const stateToken = await generateOAuthState('github'); - - // Simulate session ID mismatch (attacker trying to use victim's state) - // This would require mocking sessionStorage in tests - - // const result = await validateOAuthState(stateToken, 'different-session-id'); - // expect(result.valid).toBe(false); - // expect(result.error).toBe('session_mismatch'); - - expect(stateToken).toBeDefined(); // Placeholder - }); - - it('should mark state as used after successful validation', async () => { - const stateToken = await generateOAuthState('github'); - - const result1 = await validateOAuthState(stateToken); - expect(result1.valid).toBe(true); - - // State should now be marked as used in database - const result2 = await validateOAuthState(stateToken); - expect(result2.valid).toBe(false); - expect(result2.error).toBe('state_already_used'); - }); - }); - - describe('cleanupExpiredStates', () => { - it('should remove states older than 5 minutes', async () => { - // Generate some states - await generateOAuthState('github'); - await generateOAuthState('google'); - - // Run cleanup - const deletedCount = await cleanupExpiredStates(); - - // Should not delete fresh states - expect(deletedCount).toBe(0); - }); - - it('should preserve unexpired states', async () => { - const stateToken = await generateOAuthState('github'); - - await cleanupExpiredStates(); - - // State should still be valid - const result = await validateOAuthState(stateToken); - expect(result.valid).toBe(true); - }); - }); - - describe('Security Requirements - REQ-SEC-002', () => { - it('should prevent OAuth CSRF attacks', async () => { - // Scenario: Attacker initiates OAuth flow and tries to redirect to victim - const attackerState = await generateOAuthState('github'); - - // Victim has different session - // When callback happens with attacker's state, it should be rejected - const result = await validateOAuthState(attackerState); - - // This test proves state validation prevents session hijacking - expect(result).toBeDefined(); - }); - - it('should prevent replay attacks', async () => { - const stateToken = await generateOAuthState('github'); - - // First use succeeds - const result1 = await validateOAuthState(stateToken); - expect(result1.valid).toBe(true); - - // Replay attempt fails - const result2 = await validateOAuthState(stateToken); - expect(result2.valid).toBe(false); - }); - - it('should time out state tokens after 5 minutes', async () => { - // Prevents indefinite window for attack - const stateToken = await generateOAuthState('github'); - - expect(stateToken).toBeDefined(); - // Actual timeout test would require time mocking - }); - }); - - describe('Integration with OAuth Flow', () => { - it('should provide state token for signInWithOAuth options', async () => { - const stateToken = await generateOAuthState('github'); - - // State token should be in UUID format for use in OAuth URL - expect(stateToken).toMatch(/^[0-9a-f-]{36}$/i); - }); - - it('should validate state from callback URL parameters', async () => { - const stateToken = await generateOAuthState('github'); - - // Simulate callback with state parameter - const callbackState = stateToken; // Would come from URL params - - const result = await validateOAuthState(callbackState); - expect(result.valid).toBe(true); - }); - }); -}); diff --git a/src/lib/auth/oauth-state.ts b/src/lib/auth/oauth-state.ts deleted file mode 100644 index 9c72b3b1..00000000 --- a/src/lib/auth/oauth-state.ts +++ /dev/null @@ -1,214 +0,0 @@ -// Security Hardening: OAuth State Management -// Feature 017 - Task T021 -// Purpose: Generate and validate OAuth state tokens for CSRF protection - -import { supabase } from '@/lib/supabase/client'; -import { createLogger } from '@/lib/logger'; - -const logger = createLogger('auth:oauth'); - -// Generate UUID v4 using crypto API -function generateUUID(): string { - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return crypto.randomUUID(); - } - // Fallback using crypto.getRandomValues (available in all modern browsers) - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - // Set version 4 (bits 12-15 of time_hi_and_version) - bytes[6] = (bytes[6] & 0x0f) | 0x40; - // Set variant (bits 6-7 of clock_seq_hi_and_reserved) - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join( - '' - ); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} - -export interface OAuthStateValidationResult { - valid: boolean; - provider?: 'github' | 'google'; - error?: string; -} - -/** - * Generate a unique OAuth state token and store it in the database - * - * @param provider - OAuth provider (github or google) - * @returns State token (UUID) to include in OAuth authorization URL - * - * @example - * const stateToken = await generateOAuthState('github'); - * // Use stateToken in OAuth redirect URL - */ -export async function generateOAuthState( - provider: 'github' | 'google' -): Promise { - const stateToken = generateUUID(); - const sessionId = getSessionId(); - const returnUrl = window.location.pathname; - - try { - const { error } = await supabase.from('oauth_states').insert({ - state_token: stateToken, - provider, - session_id: sessionId, - return_url: returnUrl, - ip_address: null, // Will be set by RLS/triggers if needed - user_agent: navigator.userAgent, - }); - - if (error) { - logger.error('Failed to store OAuth state', { - error, - provider, - stateToken, - }); - // Still return the token - validation will fail later if needed - } - - return stateToken; - } catch (error) { - logger.error('Error generating OAuth state', { - error, - provider, - stateToken, - }); - // Return a token anyway - fail open for better UX - return stateToken; - } -} - -/** - * Validate OAuth state token from callback - * - * @param stateToken - State parameter from OAuth callback URL - * @returns Validation result indicating if state is valid - * - * @example - * const result = await validateOAuthState(stateFromURL); - * if (!result.valid) { - * throw new Error(result.error); - * } - */ -export async function validateOAuthState( - stateToken: string -): Promise { - if (!stateToken) { - return { - valid: false, - error: 'state_not_found', - }; - } - - try { - // Fetch the state from database - const { data: stateData, error: fetchError } = await supabase - .from('oauth_states') - .select('*') - .eq('state_token', stateToken) - .single(); - - if (fetchError || !stateData) { - return { - valid: false, - error: 'state_not_found', - }; - } - - // Check if already used - if (stateData.used) { - return { - valid: false, - error: 'state_already_used', - }; - } - - // Check if expired (5 minutes) - const expiresAt = new Date(stateData.expires_at); - if (expiresAt < new Date()) { - return { - valid: false, - error: 'state_expired', - }; - } - - // Validate session ownership (CSRF protection) - const currentSessionId = getSessionId(); - if (stateData.session_id && stateData.session_id !== currentSessionId) { - return { - valid: false, - error: 'session_mismatch', - }; - } - - // Mark state as used - const { error: updateError } = await supabase - .from('oauth_states') - .update({ used: true }) - .eq('state_token', stateToken); - - if (updateError) { - logger.error('Failed to mark state as used', { - error: updateError, - stateToken, - }); - // Continue anyway - state was valid - } - - return { - valid: true, - provider: stateData.provider as 'github' | 'google', - }; - } catch (error) { - logger.error('Error validating OAuth state', { error, stateToken }); - return { - valid: false, - error: 'validation_error', - }; - } -} - -/** - * Get or create a session ID for CSRF validation - * Uses sessionStorage to track the browser session - */ -function getSessionId(): string { - if (typeof window === 'undefined') { - return ''; - } - - const SESSION_KEY = 'oauth_session_id'; - let sessionId = sessionStorage.getItem(SESSION_KEY); - - if (!sessionId) { - sessionId = generateUUID(); - sessionStorage.setItem(SESSION_KEY, sessionId); - } - - return sessionId; -} - -/** - * Cleanup expired OAuth states (maintenance function) - * Should be called periodically or via cron job - */ -export async function cleanupExpiredStates(): Promise { - try { - const { data, error } = await supabase - .from('oauth_states') - .delete() - .lt('expires_at', new Date().toISOString()) - .select(); - - if (error) { - logger.error('Failed to cleanup expired states', { error }); - return 0; - } - - return data?.length || 0; - } catch (error) { - logger.error('Error cleaning up expired states', { error }); - return 0; - } -} diff --git a/src/lib/blog/blog-data.json b/src/lib/blog/blog-data.json index 874cea51..7d23ef6a 100644 --- a/src/lib/blog/blog-data.json +++ b/src/lib/blog/blog-data.json @@ -130,13 +130,13 @@ } }, { - "id": "post_421e318f", + "id": "post_767d500f", "slug": "authentication-supabase-oauth", - "title": "Supabase Authentication: OAuth & Security Guide", - "content": "\n# 🔒 Production-Ready Authentication with Supabase: OAuth, Security, and Real-World Implementation\n\nAuthentication is the foundation of any application that handles user data. Get it wrong, and you're exposing your users to account takeovers, data breaches, and compliance nightmares. Get it right, and your users don't even notice—they just trust you.\n\nThis post documents our implementation of production-ready authentication in ScriptHammer using [Supabase](https://supabase.com/), complete with OAuth (Open Authorization) providers, server-side rate limiting, and database-level security policies. This isn't a \"hello world\" tutorial—this is what we learned building authentication that actually ships to production.\n\n## 🗄️ Why Supabase? (vs Auth0/Firebase)\n\nAfter evaluating Auth0, Firebase Auth, and Supabase, we chose Supabase for three critical reasons:\n\n1. **Database-First Security**: Row-Level Security (RLS) policies live in PostgreSQL (Structured Query Language), not application code. Even if your API (Application Programming Interface) gets compromised, the database won't leak data.\n\n2. **No Vendor Lock-In**: Supabase runs on open-source PostgreSQL. If we ever need to migrate, we own the database schema and can export everything.\n\n3. **Developer Experience**: Built-in session management with [@supabase/ssr](https://github.com/supabase/auth-helpers) for Next.js, automatic TypeScript type generation, and real-time subscriptions all in one package.\n\nFirebase Auth is great for prototypes, but authentication-as-a-service means you're always dependent on Google's infrastructure. Auth0 is enterprise-grade but expensive at scale. Supabase gives us enterprise features with open-source flexibility.\n\n## 🔨 What We Built: Feature Overview\n\nHere's what ships in our authentication system:\n\n### 🔐 Core Authentication Flows\n\n- ✉️ **Email/Password Authentication**: Traditional sign-up with email verification\n- 🔑 **OAuth Providers**: GitHub and Google single sign-on with Cross-Site Request Forgery (CSRF) protection\n- 🔄 **Password Reset**: Secure token-based password recovery via email\n- ⏱️ **Session Management**: 7-day default sessions, 30-day \"Remember Me\" option\n\n### 🛡️ Security Hardening\n\n- 🚦 **Server-Side Rate Limiting**: 5 failed attempts per 15-minute window, enforced in PostgreSQL (client can't bypass)\n- 🔒 **OAuth CSRF Protection**: State token validation prevents session hijacking\n- 📝 **Audit Logging**: Every authentication event logged to database with Internet Protocol (IP) address and user agent\n- 🗄️ **Row-Level Security**: Database policies ensure users only see their own data\n\n### 🔧 Developer Features\n\n- 🛣️ **Protected Routes**: Middleware-based authorization checks\n- 📘 **Type Safety**: Generated TypeScript types from Supabase schema\n- ⚛️ **React Context**: Global `useAuth()` hook for accessing user session\n- 🧪 **Test Infrastructure**: Pre-configured test users for integration testing\n\nLet's dive into the implementation.\n\n## 📧 Part 1: Email/Password Auth\n\n### The Sign-Up Flow\n\nEmail/password authentication starts with user registration. Here's our `SignUpForm` component:\n\n```tsx\n// src/components/auth/SignUpForm/SignUpForm.tsx\nimport { useState } from 'react';\nimport { supabase } from '@/lib/supabase/client';\nimport { validateEmail } from '@/lib/auth/email-validator';\nimport { checkRateLimit } from '@/lib/auth/rate-limit-check';\n\nexport function SignUpForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [loading, setLoading] = useState(false);\n\n const handleSignUp = async (e: React.FormEvent) => {\n e.preventDefault();\n setLoading(true);\n\n try {\n // Validate email format and check for disposable domains\n const emailValidation = validateEmail(email);\n if (!emailValidation.valid) {\n alert(emailValidation.errors.join(', '));\n return;\n }\n\n // Server-side rate limit check (enforced in PostgreSQL)\n const rateLimit = await checkRateLimit(email, 'sign_up');\n if (!rateLimit.allowed) {\n alert(`Too many attempts. Try again after ${rateLimit.locked_until}`);\n return;\n }\n\n // Create user with Supabase Auth\n const { data, error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${window.location.origin}/auth/callback`,\n },\n });\n\n if (error) throw error;\n\n // User created - verification email sent\n alert('Check your email for the verification link!');\n } catch (error) {\n console.error('Sign up error:', error);\n } finally {\n setLoading(false);\n }\n };\n\n return (\n
\n setEmail(e.target.value)}\n placeholder=\"Email address\"\n required\n />\n setPassword(e.target.value)}\n placeholder=\"Password (min 8 chars)\"\n minLength={8}\n required\n />\n \n \n );\n}\n```\n\n### Email Validation with TLD Checks\n\nWe enhanced Supabase's built-in validation with custom checks for Top-Level Domain (TLD) validity and disposable email detection:\n\n```typescript\n// src/lib/auth/email-validator.ts\nconst VALID_TLDS = new Set([\n 'com',\n 'org',\n 'net',\n 'edu',\n 'gov',\n 'io',\n 'co',\n 'uk',\n 'us',\n 'ca',\n 'au',\n 'de',\n 'fr',\n 'it',\n 'es',\n 'app',\n 'dev',\n 'cloud',\n 'tech',\n 'ai',\n]);\n\nconst DISPOSABLE_DOMAINS = new Set([\n 'tempmail.com',\n 'throwaway.email',\n '10minutemail.com',\n 'guerrillamail.com',\n 'mailinator.com',\n]);\n\nexport function validateEmail(email: string) {\n const errors: string[] = [];\n const warnings: string[] = [];\n\n // RFC 5322 format check\n const EMAIL_REGEX =\n /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;\n\n if (!EMAIL_REGEX.test(email)) {\n errors.push('Invalid email format');\n }\n\n // TLD validation\n const tld = email.split('.').pop()?.toLowerCase();\n if (!tld || !VALID_TLDS.has(tld)) {\n errors.push('Invalid or missing top-level domain (TLD)');\n }\n\n // Disposable email detection (warning, not error)\n const domain = email.split('@')[1];\n if (domain && DISPOSABLE_DOMAINS.has(domain)) {\n warnings.push(\n 'Disposable email detected - account recovery may be limited'\n );\n }\n\n return {\n valid: errors.length === 0,\n errors,\n warnings,\n normalized: email.toLowerCase(),\n };\n}\n```\n\nWhy validate on the client AND server? Client validation provides instant feedback. Server validation (in Supabase Edge Functions) prevents malicious clients from bypassing checks.\n\n### Email Verification Flow\n\nAfter sign-up, Supabase sends a verification email with a token. The user clicks the link, which redirects to our callback page:\n\n```tsx\n// src/app/auth/callback/page.tsx\nimport { createClient } from '@/lib/supabase/server';\nimport { redirect } from 'next/navigation';\n\nexport default async function AuthCallbackPage({\n searchParams,\n}: {\n searchParams: { code?: string };\n}) {\n const supabase = await createClient();\n\n if (searchParams.code) {\n // Exchange authorization code for session\n const { error } = await supabase.auth.exchangeCodeForSession(\n searchParams.code\n );\n\n if (error) {\n return redirect('/sign-in?error=verification_failed');\n }\n\n // Email verified - redirect to dashboard\n return redirect('/profile');\n }\n\n return redirect('/sign-in');\n}\n```\n\nThis callback handles both email verification and OAuth redirects (which we'll cover next).\n\n## 🔑 Part 2: OAuth with GitHub and Google\n\n### Why OAuth?\n\nPassword fatigue is real. Users reuse passwords across sites, creating security nightmares. OAuth lets users authenticate with providers they already trust (GitHub, Google) without creating another password.\n\n### OAuth Flow with CSRF Protection\n\nOAuth has a critical vulnerability: Cross-Site Request Forgery (CSRF) attacks. An attacker can initiate an OAuth flow and trick a victim into completing it, linking the attacker's GitHub account to the victim's app account.\n\nWe prevent this with **state tokens**:\n\n```typescript\n// src/lib/auth/oauth-state.ts\nimport { supabase } from '@/lib/supabase/client';\n\n// Generate UUID v4 using crypto API (available in modern browsers and Node 16+)\nfunction generateUUID(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n // Fallback for older environments\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n const v = c === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n}\n\n/**\n * Get or create a session ID for CSRF validation\n * Uses sessionStorage to track the browser session\n */\nfunction getSessionId(): string {\n if (typeof window === 'undefined') {\n return '';\n }\n\n const SESSION_KEY = 'oauth_session_id';\n let sessionId = sessionStorage.getItem(SESSION_KEY);\n\n if (!sessionId) {\n sessionId = generateUUID();\n sessionStorage.setItem(SESSION_KEY, sessionId);\n }\n\n return sessionId;\n}\n\n/**\n * Generate a cryptographically random state token for OAuth flow\n * Stored in database with 5-minute expiration\n */\nexport async function generateOAuthState(\n provider: 'github' | 'google'\n): Promise {\n const stateToken = generateUUID(); // Cryptographically random UUID\n const sessionId = getSessionId(); // Get or create browser session ID\n\n // Store in database\n const { error } = await supabase.from('oauth_states').insert({\n state_token: stateToken,\n provider,\n session_id: sessionId, // Tie to browser session\n expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes\n });\n\n if (error) {\n throw new Error('Failed to generate OAuth state');\n }\n\n return stateToken;\n}\n\n/**\n * Validate state token from OAuth callback\n * Ensures the request originated from the same browser session\n */\nexport async function validateOAuthState(stateToken: string) {\n const { data, error } = await supabase\n .from('oauth_states')\n .select('*')\n .eq('state_token', stateToken)\n .eq('used', false) // Prevent replay attacks\n .single();\n\n if (error || !data) {\n return { valid: false, error: 'invalid_state' };\n }\n\n // Check expiration\n if (new Date(data.expires_at) < new Date()) {\n return { valid: false, error: 'state_expired' };\n }\n\n // Mark as used (single-use tokens)\n await supabase\n .from('oauth_states')\n .update({ used: true })\n .eq('state_token', stateToken);\n\n return { valid: true, provider: data.provider };\n}\n```\n\n### OAuth Button Component\n\nHere's how we initiate OAuth flows with state token protection:\n\n```tsx\n// src/components/auth/OAuthButtons/OAuthButtons.tsx\nimport { supabase } from '@/lib/supabase/client';\nimport { generateOAuthState } from '@/lib/auth/oauth-state';\n\nexport function OAuthButtons() {\n const handleOAuth = async (provider: 'github' | 'google') => {\n try {\n // Generate CSRF protection state token\n const stateToken = await generateOAuthState(provider);\n\n // Initiate OAuth flow with state parameter\n const { data, error } = await supabase.auth.signInWithOAuth({\n provider,\n options: {\n redirectTo: `${window.location.origin}/auth/callback`,\n scopes:\n provider === 'github' ? 'read:user user:email' : 'email profile',\n queryParams: {\n state: stateToken, // Include state for CSRF protection\n },\n },\n });\n\n if (error) throw error;\n\n // User redirected to provider's consent page\n } catch (error) {\n console.error('OAuth error:', error);\n alert('Failed to initiate OAuth flow');\n }\n };\n\n return (\n
\n \n\n \n
\n );\n}\n```\n\n### OAuth Callback Handling\n\nWhen the user authorizes on GitHub/Google, they're redirected back to our callback with an authorization code. We validate the state token before exchanging the code for a session:\n\n```tsx\n// src/app/auth/callback/page.tsx (extended)\nimport { validateOAuthState } from '@/lib/auth/oauth-state';\n\nexport default async function AuthCallbackPage({\n searchParams,\n}: {\n searchParams: { code?: string; state?: string };\n}) {\n const supabase = await createClient();\n\n if (searchParams.code) {\n // Validate OAuth state token (CSRF protection)\n if (searchParams.state) {\n const stateValidation = await validateOAuthState(searchParams.state);\n\n if (!stateValidation.valid) {\n return redirect('/sign-in?error=oauth_security_error');\n }\n }\n\n // Exchange authorization code for session\n const { error } = await supabase.auth.exchangeCodeForSession(\n searchParams.code\n );\n\n if (error) {\n return redirect('/sign-in?error=oauth_failed');\n }\n\n // Authenticated - redirect to dashboard\n return redirect('/profile');\n }\n\n return redirect('/sign-in');\n}\n```\n\n## 🚦 Part 3: Server-Side Rate Limiting\n\n⚠️ **Critical**: Client-side rate limiting is useless—attackers can bypass JavaScript. We implemented **PostgreSQL-based rate limiting** that's impossible to bypass:\n\n### Database Function for Rate Limiting\n\n```sql\n-- supabase/migrations/20251006_complete_monolithic_setup.sql\nCREATE TABLE rate_limit_attempts (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n identifier TEXT NOT NULL, -- Email or IP address\n attempt_type TEXT NOT NULL CHECK (attempt_type IN ('sign_in', 'sign_up', 'password_reset')),\n ip_address INET,\n user_agent TEXT,\n window_start TIMESTAMPTZ NOT NULL DEFAULT now(),\n attempt_count INTEGER NOT NULL DEFAULT 1,\n locked_until TIMESTAMPTZ, -- Lockout expiration\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE UNIQUE INDEX idx_rate_limit_unique\n ON rate_limit_attempts(identifier, attempt_type);\n\n-- Function: Check if user is rate limited\nCREATE OR REPLACE FUNCTION check_rate_limit(\n p_identifier TEXT,\n p_attempt_type TEXT,\n p_ip_address INET DEFAULT NULL\n)\nRETURNS JSON AS $$\nDECLARE\n v_record rate_limit_attempts%ROWTYPE;\n v_max_attempts INTEGER := 5;\n v_window_minutes INTEGER := 15;\n v_now TIMESTAMPTZ := now();\nBEGIN\n -- Lock row to prevent race conditions\n SELECT * INTO v_record\n FROM rate_limit_attempts\n WHERE identifier = p_identifier AND attempt_type = p_attempt_type\n FOR UPDATE SKIP LOCKED;\n\n -- Check if locked out\n IF v_record.locked_until IS NOT NULL AND v_record.locked_until > v_now THEN\n RETURN json_build_object(\n 'allowed', FALSE,\n 'remaining', 0,\n 'locked_until', v_record.locked_until,\n 'reason', 'rate_limited'\n );\n END IF;\n\n -- Reset window if expired\n IF v_record.id IS NULL OR (v_now - v_record.window_start) > (v_window_minutes || ' minutes')::INTERVAL THEN\n INSERT INTO rate_limit_attempts (identifier, attempt_type, ip_address, window_start, attempt_count)\n VALUES (p_identifier, p_attempt_type, p_ip_address, v_now, 0)\n ON CONFLICT (identifier, attempt_type) DO UPDATE\n SET window_start = v_now, attempt_count = 0, locked_until = NULL, updated_at = v_now;\n RETURN json_build_object('allowed', TRUE, 'remaining', v_max_attempts, 'locked_until', NULL);\n END IF;\n\n -- Check attempt count\n IF v_record.attempt_count < v_max_attempts THEN\n RETURN json_build_object(\n 'allowed', TRUE,\n 'remaining', v_max_attempts - v_record.attempt_count,\n 'locked_until', NULL\n );\n ELSE\n -- Lock out user\n UPDATE rate_limit_attempts\n SET locked_until = v_now + (v_window_minutes || ' minutes')::INTERVAL, updated_at = v_now\n WHERE identifier = p_identifier AND attempt_type = p_attempt_type;\n\n RETURN json_build_object(\n 'allowed', FALSE,\n 'remaining', 0,\n 'locked_until', v_now + (v_window_minutes || ' minutes')::INTERVAL,\n 'reason', 'rate_limited'\n );\n END IF;\nEND;\n$$ LANGUAGE plpgsql SECURITY DEFINER;\n```\n\n### Client-Side Rate Limit Check\n\n```typescript\n// src/lib/auth/rate-limit-check.ts\nimport { supabase } from '@/lib/supabase/client';\n\nexport interface RateLimitResult {\n allowed: boolean;\n remaining: number;\n locked_until: string | null;\n reason?: 'rate_limited';\n}\n\nexport async function checkRateLimit(\n identifier: string,\n attemptType: 'sign_in' | 'sign_up' | 'password_reset',\n ipAddress?: string\n): Promise {\n const { data, error } = await supabase.rpc('check_rate_limit', {\n p_identifier: identifier,\n p_attempt_type: attemptType,\n p_ip_address: ipAddress || null,\n });\n\n if (error) {\n console.error('Rate limit check failed:', error);\n // Fail open (allow request) rather than fail closed (block everyone)\n return { allowed: true, remaining: 5, locked_until: null };\n }\n\n return data as unknown as RateLimitResult;\n}\n\nexport async function recordFailedAttempt(\n identifier: string,\n attemptType: 'sign_in' | 'sign_up' | 'password_reset',\n ipAddress?: string\n): Promise {\n await supabase.rpc('record_failed_attempt', {\n p_identifier: identifier,\n p_attempt_type: attemptType,\n p_ip_address: ipAddress || null,\n });\n}\n```\n\nThis approach has three critical advantages:\n\n1. **Impossible to Bypass**: Enforced in PostgreSQL, not JavaScript\n2. **No External Services**: No Redis or Upstash needed\n3. **Audit Trail**: Every attempt logged with IP and user agent\n\n## 🗄️ Part 4: Row-Level Security (RLS) Policies\n\nEven if your API gets compromised, Row-Level Security (RLS) policies in PostgreSQL ensure users can't see each other's data.\n\n### Payment Data Isolation\n\n```sql\n-- Users can only view their own payment intents\nCREATE POLICY \"Users can view own payment intents\" ON payment_intents\n FOR SELECT USING (auth.uid() = template_user_id);\n\n-- Users can only create payment intents for themselves\nCREATE POLICY \"Users can create own payment intents\" ON payment_intents\n FOR INSERT WITH CHECK (auth.uid() = template_user_id);\n\n-- Payment intents are immutable (no UPDATE allowed)\nCREATE POLICY \"Payment intents are immutable\" ON payment_intents\n FOR UPDATE USING (false);\n\n-- Users cannot delete payment records\nCREATE POLICY \"Payment intents cannot be deleted by users\" ON payment_intents\n FOR DELETE USING (false);\n```\n\n### User Profile Access\n\n```sql\n-- Users view their own profile\nCREATE POLICY \"Users view own profile\" ON user_profiles\n FOR SELECT USING (auth.uid() = id);\n\n-- Users update their own profile\nCREATE POLICY \"Users update own profile\" ON user_profiles\n FOR UPDATE USING (auth.uid() = id);\n```\n\n✅ **Security Guarantee**: These policies run **at the database level**, enforced by PostgreSQL. Even if an attacker compromises your Next.js API routes, they can't query other users' data.\n\n## ⏱️ Part 5: Session & Route Protection\n\n### AuthContext for Global Session State\n\nWe use React Context to provide authentication state throughout the app:\n\n```tsx\n// src/contexts/AuthContext.tsx\n'use client';\n\nimport { createContext, useContext, useEffect, useState } from 'react';\nimport { supabase } from '@/lib/supabase/client';\nimport type { User, Session } from '@supabase/supabase-js';\n\ninterface AuthContextType {\n user: User | null;\n session: Session | null;\n loading: boolean;\n signOut: () => Promise;\n}\n\nconst AuthContext = createContext(undefined);\n\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n const [user, setUser] = useState(null);\n const [session, setSession] = useState(null);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n // Get initial session\n supabase.auth.getSession().then(({ data: { session } }) => {\n setSession(session);\n setUser(session?.user ?? null);\n setLoading(false);\n });\n\n // Listen for auth changes\n const {\n data: { subscription },\n } = supabase.auth.onAuthStateChange((_event, session) => {\n setSession(session);\n setUser(session?.user ?? null);\n });\n\n return () => subscription.unsubscribe();\n }, []);\n\n const signOut = async () => {\n await supabase.auth.signOut();\n setSession(null);\n setUser(null);\n };\n\n return (\n \n {children}\n \n );\n}\n\nexport function useAuth() {\n const context = useContext(AuthContext);\n if (!context) {\n throw new Error('useAuth must be used within AuthProvider');\n }\n return context;\n}\n```\n\n### Middleware for Protected Routes\n\nNext.js middleware runs before page rendering, making it perfect for authentication checks:\n\n```typescript\n// src/middleware.ts\nimport { type NextRequest } from 'next/server';\nimport { updateSession } from '@/lib/supabase/middleware';\n\nexport async function middleware(request: NextRequest) {\n return await updateSession(request);\n}\n\nexport const config = {\n matcher: [\n '/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',\n ],\n};\n```\n\nThe `updateSession` helper handles session validation and route protection:\n\n```typescript\n// src/lib/supabase/middleware.ts\nimport { createServerClient } from '@supabase/ssr';\nimport { type NextRequest, NextResponse } from 'next/server';\n\nexport async function updateSession(request: NextRequest) {\n const supabaseResponse = NextResponse.next({ request });\n\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll();\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value, options }) => {\n request.cookies.set(name, value);\n supabaseResponse.cookies.set(name, value, options);\n });\n },\n },\n }\n );\n\n // Get user session (refreshes expired tokens automatically)\n const {\n data: { user },\n } = await supabase.auth.getUser();\n\n // Redirect authenticated users away from auth pages\n if (\n user &&\n (request.nextUrl.pathname === '/sign-in' ||\n request.nextUrl.pathname === '/sign-up')\n ) {\n const url = request.nextUrl.clone();\n url.pathname = '/profile';\n return NextResponse.redirect(url);\n }\n\n // Redirect unauthenticated users from protected routes\n const protectedRoutes = ['/profile', '/account', '/payment-demo'];\n if (\n !user &&\n protectedRoutes.some((route) => request.nextUrl.pathname.startsWith(route))\n ) {\n const url = request.nextUrl.clone();\n url.pathname = '/sign-in';\n url.searchParams.set('redirectTo', request.nextUrl.pathname);\n return NextResponse.redirect(url);\n }\n\n return supabaseResponse;\n}\n```\n\nThis middleware pattern ensures:\n\n- Unauthenticated users can't access `/profile`, `/account`, or `/payment-demo`\n- Authenticated users don't see sign-in/sign-up pages (redirected to `/profile`)\n- Session tokens are automatically refreshed before expiration\n\n## 🧪 Part 6: Testing Authentication\n\n### Integration Tests with Vitest\n\nWe test authentication flows with real Supabase calls:\n\n```typescript\n// tests/integration/auth/sign-up-flow.test.ts\nimport { describe, it, expect } from 'vitest';\nimport { supabase } from '@/lib/supabase/client';\n\ndescribe('Sign-Up Flow', () => {\n const testEmail = process.env.TEST_USER_PRIMARY_EMAIL || 'test@example.com';\n const testPassword =\n process.env.TEST_USER_PRIMARY_PASSWORD || 'TestPassword123!';\n\n it('should sign in with valid credentials', async () => {\n const { data, error } = await supabase.auth.signInWithPassword({\n email: testEmail,\n password: testPassword,\n });\n\n expect(error).toBeNull();\n expect(data.user).toBeDefined();\n expect(data.session).toBeDefined();\n expect(data.user?.email).toBe(testEmail);\n });\n\n it('should reject invalid credentials', async () => {\n const { data, error } = await supabase.auth.signInWithPassword({\n email: testEmail,\n password: 'WrongPassword123!',\n });\n\n expect(error).toBeDefined();\n expect(error?.message).toContain('Invalid login credentials');\n expect(data.user).toBeNull();\n });\n});\n```\n\n### E2E Tests with Playwright\n\nEnd-to-End (E2E) tests verify the entire authentication flow in a real browser:\n\n```typescript\n// e2e/auth/sign-in.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Sign-In Flow', () => {\n test('should sign in successfully with valid credentials', async ({\n page,\n }) => {\n await page.goto('/sign-in');\n\n // Fill in credentials\n await page.fill(\n 'input[type=\"email\"]',\n process.env.TEST_USER_PRIMARY_EMAIL!\n );\n await page.fill(\n 'input[type=\"password\"]',\n process.env.TEST_USER_PRIMARY_PASSWORD!\n );\n\n // Submit form\n await page.click('button[type=\"submit\"]');\n\n // Should redirect to profile\n await expect(page).toHaveURL('/profile');\n await expect(page.getByText('Account Settings')).toBeVisible();\n });\n\n test('should show error with invalid credentials', async ({ page }) => {\n await page.goto('/sign-in');\n\n await page.fill('input[type=\"email\"]', 'wrong@example.com');\n await page.fill('input[type=\"password\"]', 'WrongPassword123!');\n await page.click('button[type=\"submit\"]');\n\n // Should show error message\n await expect(page.getByText(/invalid login credentials/i)).toBeVisible();\n });\n});\n```\n\n## 💡 Part 7: What We Learned\n\n### Lesson 1: Cookies vs localStorage\n\nFor static sites with no server-side code exchange, we use `localStorage` for session tokens with Supabase's implicit flow:\n\n```typescript\n// src/lib/supabase/client.ts\nexport function createClient(): SupabaseClient {\n const supabaseInstance = createSupabaseClient(\n supabaseUrl,\n supabaseAnonKey,\n {\n auth: {\n // Use implicit flow for static sites (no server-side code exchange)\n flowType: 'implicit',\n // Store session in localStorage\n storage:\n typeof window !== 'undefined' ? window.localStorage : undefined,\n autoRefreshToken: true,\n persistSession: true,\n detectSessionInUrl: true,\n },\n }\n );\n\n return supabaseInstance;\n}\n```\n\nFor server-side authentication (SSR), use `@supabase/ssr` with `httpOnly` cookies as shown in the middleware section.\n\n### Lesson 2: Test Isolation & Cleanup\n\nOur tests initially failed because of leftover database state. When testing rate limiting or OAuth flows, **always clean up database records in `beforeEach`**:\n\n```typescript\nbeforeEach(async () => {\n // Clean up rate limit attempts\n await supabase\n .from('rate_limit_attempts')\n .delete()\n .eq('identifier', testEmail);\n\n // Clean up OAuth states\n await supabase\n .from('oauth_states')\n .delete()\n .neq('id', '00000000-0000-0000-0000-000000000000');\n});\n```\n\n### Lesson 3: Isolate OAuth State\n\nWhen running multiple OAuth tests, shared `localStorage` caused state token collisions. Solution: **use separate storage keys per test client**:\n\n```typescript\nconst userAClient = createClient(supabaseUrl, supabaseAnonKey, {\n auth: {\n storageKey: 'test-user-a-session', // Unique per client\n },\n});\n\nconst userBClient = createClient(supabaseUrl, supabaseAnonKey, {\n auth: {\n storageKey: 'test-user-b-session', // Different key\n },\n});\n```\n\n### Lesson 4: Fail Open on Rate Errors\n\nWhen the rate limit database query fails, **fail open** (allow the request) rather than **fail closed** (block everyone):\n\n```typescript\nexport async function checkRateLimit(...args) {\n const { data, error } = await supabase.rpc('check_rate_limit', ...);\n\n if (error) {\n console.error('Rate limit check failed:', error);\n // Fail open - allow request rather than blocking everyone\n return { allowed: true, remaining: 5, locked_until: null };\n }\n\n return data;\n}\n```\n\nThis prevents a database outage from locking out all users.\n\n## ✅ Conclusion: Authentication Done Right\n\nBuilding production authentication isn't about copying Auth0's API. It's about understanding the security principles:\n\n1. **Defense in Depth**: Rate limiting in PostgreSQL, RLS policies at database level, CSRF tokens for OAuth\n2. **Fail Safely**: Fail open on errors, provide clear error messages, don't lock out legitimate users\n3. **Test Realistically**: Integration tests with real Supabase, E2E tests in real browsers, database cleanup between tests\n\nThe result? An authentication system that ships to production, passes security audits, and users don't even notice—because it just works.\n\nNext up: [Offline-First Payment System with Stripe and PayPal](/blog/offline-payment-system-stripe-paypal) - how we handle payments on static sites with Supabase Edge Functions.\n\n---\n\n**Want to see the full implementation?** Check out the [ScriptHammer GitHub repository](https://github.com/TortoiseWolfe/ScriptHammer).\n", + "title": "Supabase Authentication: OAuth & Security", + "content": "\n# 🔒 Production-Ready Authentication with Supabase: OAuth, Security, and Real-World Implementation\n\nAuthentication is the foundation of any application that handles user data. Get it wrong, and you're exposing your users to account takeovers, data breaches, and compliance nightmares. Get it right, and your users don't even notice—they just trust you.\n\nThis post documents our implementation of production-ready authentication in ScriptHammer using [Supabase](https://supabase.com/), complete with OAuth (Open Authorization) providers, server-side rate limiting, and database-level security policies. This isn't a \"hello world\" tutorial—this is what we learned building authentication that actually ships to production.\n\n## 🗄️ Why Supabase? (vs Auth0/Firebase)\n\nAfter evaluating Auth0, Firebase Auth, and Supabase, we chose Supabase for three critical reasons:\n\n1. **Database-First Security**: Row-Level Security (RLS) policies live in PostgreSQL (Structured Query Language), not application code. Even if your API (Application Programming Interface) gets compromised, the database won't leak data.\n\n2. **No Vendor Lock-In**: Supabase runs on open-source PostgreSQL. If we ever need to migrate, we own the database schema and can export everything.\n\n3. **Developer Experience**: Built-in session management with [@supabase/ssr](https://github.com/supabase/auth-helpers) for Next.js, automatic TypeScript type generation, and real-time subscriptions all in one package.\n\nFirebase Auth is great for prototypes, but authentication-as-a-service means you're always dependent on Google's infrastructure. Auth0 is enterprise-grade but expensive at scale. Supabase gives us enterprise features with open-source flexibility.\n\n## 🔨 What We Built: Feature Overview\n\nHere's what ships in our authentication system:\n\n### 🔐 Core Authentication Flows\n\n- ✉️ **Email/Password Authentication**: Traditional sign-up with email verification\n- 🔑 **OAuth Providers**: GitHub and Google single sign-on with Cross-Site Request Forgery (CSRF) protection\n- 🔄 **Password Reset**: Secure token-based password recovery via email\n- ⏱️ **Session Management**: 7-day default sessions, 30-day \"Remember Me\" option\n\n### 🛡️ Security Hardening\n\n- 🚦 **Server-Side Rate Limiting**: 5 failed attempts per 15-minute window, enforced in PostgreSQL (client can't bypass)\n- 🔒 **OAuth CSRF Protection**: Supabase's built-in OAuth2 `state` parameter prevents session hijacking\n- 📝 **Audit Logging**: Every authentication event logged to database with Internet Protocol (IP) address and user agent\n- 🗄️ **Row-Level Security**: Database policies ensure users only see their own data\n\n### 🔧 Developer Features\n\n- 🛣️ **Protected Routes**: Middleware-based authorization checks\n- 📘 **Type Safety**: Generated TypeScript types from Supabase schema\n- ⚛️ **React Context**: Global `useAuth()` hook for accessing user session\n- 🧪 **Test Infrastructure**: Pre-configured test users for integration testing\n\nLet's dive into the implementation.\n\n## 📧 Part 1: Email/Password Auth\n\n### The Sign-Up Flow\n\nEmail/password authentication starts with user registration. Here's our `SignUpForm` component:\n\n```tsx\n// src/components/auth/SignUpForm/SignUpForm.tsx\nimport { useState } from 'react';\nimport { supabase } from '@/lib/supabase/client';\nimport { validateEmail } from '@/lib/auth/email-validator';\nimport { checkRateLimit } from '@/lib/auth/rate-limit-check';\n\nexport function SignUpForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [loading, setLoading] = useState(false);\n\n const handleSignUp = async (e: React.FormEvent) => {\n e.preventDefault();\n setLoading(true);\n\n try {\n // Validate email format and check for disposable domains\n const emailValidation = validateEmail(email);\n if (!emailValidation.valid) {\n alert(emailValidation.errors.join(', '));\n return;\n }\n\n // Server-side rate limit check (enforced in PostgreSQL)\n const rateLimit = await checkRateLimit(email, 'sign_up');\n if (!rateLimit.allowed) {\n alert(`Too many attempts. Try again after ${rateLimit.locked_until}`);\n return;\n }\n\n // Create user with Supabase Auth\n const { data, error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${window.location.origin}/auth/callback`,\n },\n });\n\n if (error) throw error;\n\n // User created - verification email sent\n alert('Check your email for the verification link!');\n } catch (error) {\n console.error('Sign up error:', error);\n } finally {\n setLoading(false);\n }\n };\n\n return (\n
\n setEmail(e.target.value)}\n placeholder=\"Email address\"\n required\n />\n setPassword(e.target.value)}\n placeholder=\"Password (min 8 chars)\"\n minLength={8}\n required\n />\n \n \n );\n}\n```\n\n### Email Validation with TLD Checks\n\nWe enhanced Supabase's built-in validation with custom checks for Top-Level Domain (TLD) validity and disposable email detection:\n\n```typescript\n// src/lib/auth/email-validator.ts\nconst VALID_TLDS = new Set([\n 'com',\n 'org',\n 'net',\n 'edu',\n 'gov',\n 'io',\n 'co',\n 'uk',\n 'us',\n 'ca',\n 'au',\n 'de',\n 'fr',\n 'it',\n 'es',\n 'app',\n 'dev',\n 'cloud',\n 'tech',\n 'ai',\n]);\n\nconst DISPOSABLE_DOMAINS = new Set([\n 'tempmail.com',\n 'throwaway.email',\n '10minutemail.com',\n 'guerrillamail.com',\n 'mailinator.com',\n]);\n\nexport function validateEmail(email: string) {\n const errors: string[] = [];\n const warnings: string[] = [];\n\n // RFC 5322 format check\n const EMAIL_REGEX =\n /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;\n\n if (!EMAIL_REGEX.test(email)) {\n errors.push('Invalid email format');\n }\n\n // TLD validation\n const tld = email.split('.').pop()?.toLowerCase();\n if (!tld || !VALID_TLDS.has(tld)) {\n errors.push('Invalid or missing top-level domain (TLD)');\n }\n\n // Disposable email detection (warning, not error)\n const domain = email.split('@')[1];\n if (domain && DISPOSABLE_DOMAINS.has(domain)) {\n warnings.push(\n 'Disposable email detected - account recovery may be limited'\n );\n }\n\n return {\n valid: errors.length === 0,\n errors,\n warnings,\n normalized: email.toLowerCase(),\n };\n}\n```\n\nWhy validate on the client AND server? Client validation provides instant feedback. Server validation (in Supabase Edge Functions) prevents malicious clients from bypassing checks.\n\n### Email Verification Flow\n\nAfter sign-up, Supabase sends a verification email with a token. The user clicks the link, which redirects to our callback page:\n\n```tsx\n// src/app/auth/callback/page.tsx\nimport { createClient } from '@/lib/supabase/server';\nimport { redirect } from 'next/navigation';\n\nexport default async function AuthCallbackPage({\n searchParams,\n}: {\n searchParams: { code?: string };\n}) {\n const supabase = await createClient();\n\n if (searchParams.code) {\n // Exchange authorization code for session\n const { error } = await supabase.auth.exchangeCodeForSession(\n searchParams.code\n );\n\n if (error) {\n return redirect('/sign-in?error=verification_failed');\n }\n\n // Email verified - redirect to dashboard\n return redirect('/profile');\n }\n\n return redirect('/sign-in');\n}\n```\n\nThis callback handles both email verification and OAuth redirects (which we'll cover next).\n\n## 🔑 Part 2: OAuth with GitHub and Google\n\n### Why OAuth?\n\nPassword fatigue is real. Users reuse passwords across sites, creating security nightmares. OAuth lets users authenticate with providers they already trust (GitHub, Google) without creating another password.\n\n### 🔒 OAuth Flow with CSRF Protection\n\nOAuth has a critical vulnerability: Cross-Site Request Forgery (CSRF) attacks. An attacker can initiate an OAuth flow and trick a victim into completing it, linking the attacker's GitHub account to the victim's app account.\n\n💡 **The good news**: with Supabase you don't hand-roll CSRF protection. The Supabase client automatically generates a cryptographically random OAuth2 `state` parameter, ties it to the browser, and verifies it when the provider redirects back — rejecting any mismatch. That is the standard, provider-recommended OAuth CSRF defense, so a self-managed `state`-token table only duplicates (more weakly) what Supabase already does. So the entire OAuth entry point is just:\n\n```tsx\n// src/components/auth/OAuthButtons/OAuthButtons.tsx\nimport { supabase } from '@/lib/supabase/client';\n\nexport function OAuthButtons() {\n const handleOAuth = async (provider: 'github' | 'google') => {\n try {\n // Supabase handles CSRF protection via its built-in OAuth2 state\n // parameter — no need to manually manage state tokens.\n const { error } = await supabase.auth.signInWithOAuth({\n provider,\n options: {\n redirectTo: `${window.location.origin}/auth/callback`,\n scopes:\n provider === 'github' ? 'read:user user:email' : 'email profile',\n },\n });\n\n if (error) throw error;\n\n // User redirected to provider's consent page\n } catch (error) {\n console.error('OAuth error:', error);\n alert('Failed to initiate OAuth flow');\n }\n };\n\n return (\n
\n \n\n \n
\n );\n}\n```\n\n⚠️ **Gotcha**: because ScriptHammer is a **static export** (no server to run a code exchange), the client is configured with `flowType: 'implicit'` (see `src/lib/supabase/client.ts`) — the provider returns the session token in the URL fragment and supabase-js picks it up on the callback. The `state` CSRF check is automatic either way; just register your callback URL in the Supabase dashboard's redirect allow-list. (A server-rendered app would instead use `flowType: 'pkce'` + `exchangeCodeForSession`.)\n\n### OAuth Callback Handling\n\nWhen the user authorizes on GitHub/Google, they're redirected back to our callback. Under the implicit flow, supabase-js reads the session token from the URL fragment and validates the `state` parameter internally — so the callback doesn't hand-check anything; it just waits for the client to reflect the authenticated session:\n\n```tsx\n// src/app/auth/callback/page.tsx (simplified)\n'use client';\nimport { useEffect } from 'react';\nimport { useRouter } from 'next/navigation';\nimport { useAuth } from '@/hooks/useAuth';\n\nexport default function AuthCallbackPage() {\n const { user, isLoading } = useAuth();\n const router = useRouter();\n\n useEffect(() => {\n if (isLoading) return;\n\n // Supabase handles state validation internally — no manual check needed.\n // supabase-js parses the token from the URL fragment and fires the auth\n // state change; we just redirect once the session is present.\n if (user) {\n // ...populate the OAuth profile (non-blocking), then:\n router.replace('/profile');\n } else {\n router.replace('/sign-in?error=oauth_failed');\n }\n }, [user, isLoading, router]);\n\n return

Completing sign-in…

;\n}\n```\n\n## 🚦 Part 3: Server-Side Rate Limiting\n\n⚠️ **Critical**: Client-side rate limiting is useless—attackers can bypass JavaScript. We implemented **PostgreSQL-based rate limiting** that's impossible to bypass:\n\n### Database Function for Rate Limiting\n\n```sql\n-- supabase/migrations/20251006_complete_monolithic_setup.sql\nCREATE TABLE rate_limit_attempts (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n identifier TEXT NOT NULL, -- Email or IP address\n attempt_type TEXT NOT NULL CHECK (attempt_type IN ('sign_in', 'sign_up', 'password_reset')),\n ip_address INET,\n user_agent TEXT,\n window_start TIMESTAMPTZ NOT NULL DEFAULT now(),\n attempt_count INTEGER NOT NULL DEFAULT 1,\n locked_until TIMESTAMPTZ, -- Lockout expiration\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE UNIQUE INDEX idx_rate_limit_unique\n ON rate_limit_attempts(identifier, attempt_type);\n\n-- Function: Check if user is rate limited\nCREATE OR REPLACE FUNCTION check_rate_limit(\n p_identifier TEXT,\n p_attempt_type TEXT,\n p_ip_address INET DEFAULT NULL\n)\nRETURNS JSON AS $$\nDECLARE\n v_record rate_limit_attempts%ROWTYPE;\n v_max_attempts INTEGER := 5;\n v_window_minutes INTEGER := 15;\n v_now TIMESTAMPTZ := now();\nBEGIN\n -- Lock row to prevent race conditions\n SELECT * INTO v_record\n FROM rate_limit_attempts\n WHERE identifier = p_identifier AND attempt_type = p_attempt_type\n FOR UPDATE SKIP LOCKED;\n\n -- Check if locked out\n IF v_record.locked_until IS NOT NULL AND v_record.locked_until > v_now THEN\n RETURN json_build_object(\n 'allowed', FALSE,\n 'remaining', 0,\n 'locked_until', v_record.locked_until,\n 'reason', 'rate_limited'\n );\n END IF;\n\n -- Reset window if expired\n IF v_record.id IS NULL OR (v_now - v_record.window_start) > (v_window_minutes || ' minutes')::INTERVAL THEN\n INSERT INTO rate_limit_attempts (identifier, attempt_type, ip_address, window_start, attempt_count)\n VALUES (p_identifier, p_attempt_type, p_ip_address, v_now, 0)\n ON CONFLICT (identifier, attempt_type) DO UPDATE\n SET window_start = v_now, attempt_count = 0, locked_until = NULL, updated_at = v_now;\n RETURN json_build_object('allowed', TRUE, 'remaining', v_max_attempts, 'locked_until', NULL);\n END IF;\n\n -- Check attempt count\n IF v_record.attempt_count < v_max_attempts THEN\n RETURN json_build_object(\n 'allowed', TRUE,\n 'remaining', v_max_attempts - v_record.attempt_count,\n 'locked_until', NULL\n );\n ELSE\n -- Lock out user\n UPDATE rate_limit_attempts\n SET locked_until = v_now + (v_window_minutes || ' minutes')::INTERVAL, updated_at = v_now\n WHERE identifier = p_identifier AND attempt_type = p_attempt_type;\n\n RETURN json_build_object(\n 'allowed', FALSE,\n 'remaining', 0,\n 'locked_until', v_now + (v_window_minutes || ' minutes')::INTERVAL,\n 'reason', 'rate_limited'\n );\n END IF;\nEND;\n$$ LANGUAGE plpgsql SECURITY DEFINER;\n```\n\n### Client-Side Rate Limit Check\n\n```typescript\n// src/lib/auth/rate-limit-check.ts\nimport { supabase } from '@/lib/supabase/client';\n\nexport interface RateLimitResult {\n allowed: boolean;\n remaining: number;\n locked_until: string | null;\n reason?: 'rate_limited';\n}\n\nexport async function checkRateLimit(\n identifier: string,\n attemptType: 'sign_in' | 'sign_up' | 'password_reset',\n ipAddress?: string\n): Promise {\n const { data, error } = await supabase.rpc('check_rate_limit', {\n p_identifier: identifier,\n p_attempt_type: attemptType,\n p_ip_address: ipAddress || null,\n });\n\n if (error) {\n console.error('Rate limit check failed:', error);\n // Fail open (allow request) rather than fail closed (block everyone)\n return { allowed: true, remaining: 5, locked_until: null };\n }\n\n return data as unknown as RateLimitResult;\n}\n\nexport async function recordFailedAttempt(\n identifier: string,\n attemptType: 'sign_in' | 'sign_up' | 'password_reset',\n ipAddress?: string\n): Promise {\n await supabase.rpc('record_failed_attempt', {\n p_identifier: identifier,\n p_attempt_type: attemptType,\n p_ip_address: ipAddress || null,\n });\n}\n```\n\nThis approach has three critical advantages:\n\n1. **Impossible to Bypass**: Enforced in PostgreSQL, not JavaScript\n2. **No External Services**: No Redis or Upstash needed\n3. **Audit Trail**: Every attempt logged with IP and user agent\n\n## 🗄️ Part 4: Row-Level Security (RLS) Policies\n\nEven if your API gets compromised, Row-Level Security (RLS) policies in PostgreSQL ensure users can't see each other's data.\n\n### Payment Data Isolation\n\n```sql\n-- Users can only view their own payment intents\nCREATE POLICY \"Users can view own payment intents\" ON payment_intents\n FOR SELECT USING (auth.uid() = template_user_id);\n\n-- Users can only create payment intents for themselves\nCREATE POLICY \"Users can create own payment intents\" ON payment_intents\n FOR INSERT WITH CHECK (auth.uid() = template_user_id);\n\n-- Payment intents are immutable (no UPDATE allowed)\nCREATE POLICY \"Payment intents are immutable\" ON payment_intents\n FOR UPDATE USING (false);\n\n-- Users cannot delete payment records\nCREATE POLICY \"Payment intents cannot be deleted by users\" ON payment_intents\n FOR DELETE USING (false);\n```\n\n### User Profile Access\n\n```sql\n-- Users view their own profile\nCREATE POLICY \"Users view own profile\" ON user_profiles\n FOR SELECT USING (auth.uid() = id);\n\n-- Users update their own profile\nCREATE POLICY \"Users update own profile\" ON user_profiles\n FOR UPDATE USING (auth.uid() = id);\n```\n\n✅ **Security Guarantee**: These policies run **at the database level**, enforced by PostgreSQL. Even if an attacker compromises your Next.js API routes, they can't query other users' data.\n\n## ⏱️ Part 5: Session & Route Protection\n\n### AuthContext for Global Session State\n\nWe use React Context to provide authentication state throughout the app:\n\n```tsx\n// src/contexts/AuthContext.tsx\n'use client';\n\nimport { createContext, useContext, useEffect, useState } from 'react';\nimport { supabase } from '@/lib/supabase/client';\nimport type { User, Session } from '@supabase/supabase-js';\n\ninterface AuthContextType {\n user: User | null;\n session: Session | null;\n loading: boolean;\n signOut: () => Promise;\n}\n\nconst AuthContext = createContext(undefined);\n\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n const [user, setUser] = useState(null);\n const [session, setSession] = useState(null);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n // Get initial session\n supabase.auth.getSession().then(({ data: { session } }) => {\n setSession(session);\n setUser(session?.user ?? null);\n setLoading(false);\n });\n\n // Listen for auth changes\n const {\n data: { subscription },\n } = supabase.auth.onAuthStateChange((_event, session) => {\n setSession(session);\n setUser(session?.user ?? null);\n });\n\n return () => subscription.unsubscribe();\n }, []);\n\n const signOut = async () => {\n await supabase.auth.signOut();\n setSession(null);\n setUser(null);\n };\n\n return (\n \n {children}\n \n );\n}\n\nexport function useAuth() {\n const context = useContext(AuthContext);\n if (!context) {\n throw new Error('useAuth must be used within AuthProvider');\n }\n return context;\n}\n```\n\n### Client-Side Route Protection with ``\n\n> 📝 **Note (updated):** an earlier version of this post documented Next.js middleware (`src/middleware.ts`) for route protection. That pattern conflicts with `output: 'export'` (the static-export config ScriptHammer uses to deploy to GitHub Pages) — Next.js logs a `Middleware cannot be used with \"output: export\"` warning, and the middleware silently doesn't run in production. The implementation moved to a client-side guard component; the rewrite below reflects what actually ships on scripthammer.com.\n\nFor static-site deployments, route protection happens in a **client component** that wraps the page content and checks the auth context. The protected page imports the guard and renders its content inside:\n\n```tsx\n// src/app/profile/page.tsx\nimport ProtectedRoute from '@/components/auth/ProtectedRoute';\nimport UserProfileCard from '@/components/auth/UserProfileCard';\n\nexport default function ProfilePage() {\n return (\n \n \n \n );\n}\n```\n\nThe guard reads the `useAuth()` hook and either renders the children, shows a \"please sign in\" card, or redirects to the sign-in page with a `returnUrl`:\n\n```tsx\n// src/components/auth/ProtectedRoute/ProtectedRoute.tsx\n'use client';\n\nimport { useRouter, usePathname } from 'next/navigation';\nimport { useEffect, useRef } from 'react';\nimport { useAuth } from '@/contexts/AuthContext';\n\nexport default function ProtectedRoute({\n children,\n redirectTo = '/sign-in',\n}: {\n children: React.ReactNode;\n redirectTo?: string;\n}) {\n const { isAuthenticated, isLoading } = useAuth();\n const router = useRouter();\n const pathname = usePathname() || '/';\n const wasAuthenticated = useRef(false);\n\n // Remember if this mount was ever authenticated, so a transient\n // token-refresh flip doesn't redirect the user mid-interaction.\n useEffect(() => {\n if (isAuthenticated) wasAuthenticated.current = true;\n }, [isAuthenticated]);\n\n useEffect(() => {\n if (isLoading || isAuthenticated) return;\n if (wasAuthenticated.current) return; // ignore transient flips\n\n const returnUrl = encodeURIComponent(pathname);\n const timer = setTimeout(() => {\n router.push(`${redirectTo}?returnUrl=${returnUrl}`);\n }, 500);\n\n return () => clearTimeout(timer);\n }, [isAuthenticated, isLoading, router, redirectTo, pathname]);\n\n if (isLoading) {\n return ;\n }\n\n if (!isAuthenticated && !wasAuthenticated.current) {\n return (\n
\n

Authentication Required

\n \n Sign In\n \n
\n );\n }\n\n return <>{children};\n}\n```\n\nThis pattern handles three things the server middleware was responsible for:\n\n- **Unauthenticated visitors** to `/profile`, `/account`, `/payment-demo` see a \"Sign In\" prompt instead of the page content, and get redirected to `/sign-in?returnUrl=…`.\n- **Token refreshes** that briefly flip `isAuthenticated` to false (a real Supabase behavior) are debounced by 500 ms and tracked via a `wasAuthenticated` ref, so the user isn't yanked away mid-interaction.\n- **Authenticated users browsing to `/sign-in`** are redirected away via `AuthContext.signOut()`'s `window.location.href = '/'` pattern combined with the sign-in page's own auth-aware effect.\n\n**Trade-off versus middleware:** because the check happens in the browser, the protected page's HTML and bundled JS are still served — a determined user could read them in DevTools. The actual data (which is what matters) is protected at the database level by [Row-Level Security policies](#part-4-row-level-security-rls-policies). The guard is a UX layer; **RLS is the security layer**. Defense in depth.\n\nIf you're deploying to a Node host (Vercel, your own server) where middleware does run, the original middleware pattern is a perfectly good fit — just remove `output: 'export'` from `next.config.ts` and add the middleware file back. The choice is \"where does the auth check run, browser or server?\" rather than \"which one is correct?\"\n\n## 🧪 Part 6: Testing Authentication\n\n### Integration Tests with Vitest\n\nWe test authentication flows with real Supabase calls:\n\n```typescript\n// tests/integration/auth/sign-up-flow.test.ts\nimport { describe, it, expect } from 'vitest';\nimport { supabase } from '@/lib/supabase/client';\n\ndescribe('Sign-Up Flow', () => {\n const testEmail = process.env.TEST_USER_PRIMARY_EMAIL || 'test@example.com';\n const testPassword =\n process.env.TEST_USER_PRIMARY_PASSWORD || 'TestPassword123!';\n\n it('should sign in with valid credentials', async () => {\n const { data, error } = await supabase.auth.signInWithPassword({\n email: testEmail,\n password: testPassword,\n });\n\n expect(error).toBeNull();\n expect(data.user).toBeDefined();\n expect(data.session).toBeDefined();\n expect(data.user?.email).toBe(testEmail);\n });\n\n it('should reject invalid credentials', async () => {\n const { data, error } = await supabase.auth.signInWithPassword({\n email: testEmail,\n password: 'WrongPassword123!',\n });\n\n expect(error).toBeDefined();\n expect(error?.message).toContain('Invalid login credentials');\n expect(data.user).toBeNull();\n });\n});\n```\n\n### E2E Tests with Playwright\n\nEnd-to-End (E2E) tests verify the entire authentication flow in a real browser:\n\n```typescript\n// e2e/auth/sign-in.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Sign-In Flow', () => {\n test('should sign in successfully with valid credentials', async ({\n page,\n }) => {\n await page.goto('/sign-in');\n\n // Fill in credentials\n await page.fill(\n 'input[type=\"email\"]',\n process.env.TEST_USER_PRIMARY_EMAIL!\n );\n await page.fill(\n 'input[type=\"password\"]',\n process.env.TEST_USER_PRIMARY_PASSWORD!\n );\n\n // Submit form\n await page.click('button[type=\"submit\"]');\n\n // Should redirect to profile\n await expect(page).toHaveURL('/profile');\n await expect(page.getByText('Account Settings')).toBeVisible();\n });\n\n test('should show error with invalid credentials', async ({ page }) => {\n await page.goto('/sign-in');\n\n await page.fill('input[type=\"email\"]', 'wrong@example.com');\n await page.fill('input[type=\"password\"]', 'WrongPassword123!');\n await page.click('button[type=\"submit\"]');\n\n // Should show error message\n await expect(page.getByText(/invalid login credentials/i)).toBeVisible();\n });\n});\n```\n\n## 💡 Part 7: What We Learned\n\n### Lesson 1: Cookies vs localStorage\n\nFor static sites with no server-side code exchange, we use `localStorage` for session tokens with Supabase's implicit flow:\n\n```typescript\n// src/lib/supabase/client.ts\nexport function createClient(): SupabaseClient {\n const supabaseInstance = createSupabaseClient(\n supabaseUrl,\n supabaseAnonKey,\n {\n auth: {\n // Use implicit flow for static sites (no server-side code exchange)\n flowType: 'implicit',\n // Store session in localStorage\n storage:\n typeof window !== 'undefined' ? window.localStorage : undefined,\n autoRefreshToken: true,\n persistSession: true,\n detectSessionInUrl: true,\n },\n }\n );\n\n return supabaseInstance;\n}\n```\n\nFor server-side authentication (SSR), use `@supabase/ssr` with `httpOnly` cookies as shown in the middleware section.\n\n### Lesson 2: Test Isolation & Cleanup\n\nOur tests initially failed because of leftover database state. When testing rate limiting or OAuth flows, **always clean up database records in `beforeEach`**:\n\n```typescript\nbeforeEach(async () => {\n // Clean up rate limit attempts\n await supabase\n .from('rate_limit_attempts')\n .delete()\n .eq('identifier', testEmail);\n});\n```\n\n### Lesson 3: Isolate OAuth State\n\nWhen running multiple OAuth tests, shared `localStorage` caused state token collisions. Solution: **use separate storage keys per test client**:\n\n```typescript\nconst userAClient = createClient(supabaseUrl, supabaseAnonKey, {\n auth: {\n storageKey: 'test-user-a-session', // Unique per client\n },\n});\n\nconst userBClient = createClient(supabaseUrl, supabaseAnonKey, {\n auth: {\n storageKey: 'test-user-b-session', // Different key\n },\n});\n```\n\n### Lesson 4: Fail Open on Rate Errors\n\nWhen the rate limit database query fails, **fail open** (allow the request) rather than **fail closed** (block everyone):\n\n```typescript\nexport async function checkRateLimit(...args) {\n const { data, error } = await supabase.rpc('check_rate_limit', ...);\n\n if (error) {\n console.error('Rate limit check failed:', error);\n // Fail open - allow request rather than blocking everyone\n return { allowed: true, remaining: 5, locked_until: null };\n }\n\n return data;\n}\n```\n\nThis prevents a database outage from locking out all users.\n\n## ✅ Conclusion: Authentication Done Right\n\nBuilding production authentication isn't about copying Auth0's API. It's about understanding the security principles:\n\n1. **Defense in Depth**: Rate limiting in PostgreSQL, RLS policies at database level, CSRF tokens for OAuth\n2. **Fail Safely**: Fail open on errors, provide clear error messages, don't lock out legitimate users\n3. **Test Realistically**: Integration tests with real Supabase, E2E tests in real browsers, database cleanup between tests\n\nThe result? An authentication system that ships to production, passes security audits, and users don't even notice—because it just works.\n\nNext up: [Offline-First Payment System with Stripe and PayPal](/blog/offline-payment-system-stripe-paypal) - how we handle payments on static sites with Supabase Edge Functions.\n\n---\n\n**Want to see the full implementation?** Check out the [ScriptHammer GitHub repository](https://github.com/TortoiseWolfe/ScriptHammer).\n", "excerpt": "Secure authentication with Supabase, OAuth providers, server-side rate limiting, and Row-Level Security in Next.js 15 & PostgreSQL.", "publishedAt": "2025-10-08T00:00:00.000Z", - "updatedAt": "2025-10-08T14:56:16.876Z", + "updatedAt": "2026-07-05T06:18:53.169Z", "status": "published", "author": { "id": "default", @@ -155,8 +155,8 @@ "tutorials", "security" ], - "readingTime": 17, - "wordCount": 3391, + "readingTime": 18, + "wordCount": 3482, "showToc": true, "showAuthor": true, "showShareButtons": true, @@ -165,7 +165,7 @@ "featuredImageAlt": "Production-Ready Authentication with Supabase - OAuth, Security Hardening, and PostgreSQL" }, "seo": { - "title": "Supabase Authentication: OAuth & Security Guide", + "title": "Supabase Authentication: OAuth & Security", "description": "Secure authentication with Supabase, OAuth providers, server-side rate limiting, and Row-Level Security in Next.js 15 & PostgreSQL.", "keywords": [ "authentication", @@ -181,7 +181,7 @@ "twitterCard": "summary_large_image" }, "frontMatter": { - "title": "Supabase Authentication: OAuth & Security Guide", + "title": "Supabase Authentication: OAuth & Security", "author": "TortoiseWolfe", "date": "2025-10-08T00:00:00.000Z", "slug": "authentication-supabase-oauth", @@ -534,4 +534,4 @@ "DX", "documentation" ] -} \ No newline at end of file +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 7aae097e..4afe6e80 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -403,45 +403,6 @@ export type Database = { }, ]; }; - oauth_states: { - Row: { - created_at: string; - expires_at: string; - id: string; - ip_address: unknown; - provider: string; - return_url: string | null; - session_id: string | null; - state_token: string; - used: boolean; - user_agent: string | null; - }; - Insert: { - created_at?: string; - expires_at?: string; - id?: string; - ip_address?: unknown; - provider: string; - return_url?: string | null; - session_id?: string | null; - state_token: string; - used?: boolean; - user_agent?: string | null; - }; - Update: { - created_at?: string; - expires_at?: string; - id?: string; - ip_address?: unknown; - provider?: string; - return_url?: string | null; - session_id?: string | null; - state_token?: string; - used?: boolean; - user_agent?: string | null; - }; - Relationships: []; - }; payment_intents: { Row: { amount: number; diff --git a/supabase/migrations/20251006_complete_monolithic_setup.sql b/supabase/migrations/20251006_complete_monolithic_setup.sql index 085712b4..3046ad9e 100644 --- a/supabase/migrations/20251006_complete_monolithic_setup.sql +++ b/supabase/migrations/20251006_complete_monolithic_setup.sql @@ -313,55 +313,11 @@ CREATE POLICY "Service role only access" ON rate_limit_attempts COMMENT ON POLICY "Service role only access" ON rate_limit_attempts IS 'Rate limiting data is system-managed. Only service role can access.'; --- OAuth state tracking (CSRF protection) -CREATE TABLE IF NOT EXISTS oauth_states ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - state_token TEXT NOT NULL UNIQUE, - provider TEXT NOT NULL CHECK (provider IN ('github', 'google')), - session_id TEXT, - return_url TEXT, - ip_address INET, - user_agent TEXT, - used BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '5 minutes') -); - -CREATE INDEX IF NOT EXISTS idx_oauth_states_token ON oauth_states(state_token) WHERE used = FALSE; -CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at) WHERE used = FALSE; -CREATE INDEX IF NOT EXISTS idx_oauth_states_session ON oauth_states(session_id); - -COMMENT ON TABLE oauth_states IS 'OAuth state tokens - prevents session hijacking'; - --- Enable RLS on oauth_states (CSRF protection tokens) -ALTER TABLE oauth_states ENABLE ROW LEVEL SECURITY; - --- Allow anyone to insert state tokens (for OAuth flow initiation) -DROP POLICY IF EXISTS "Anyone can create state tokens" ON oauth_states; -CREATE POLICY "Anyone can create state tokens" ON oauth_states - FOR INSERT - WITH CHECK (true); - --- Allow anyone to read state tokens (for validation during OAuth callback) -DROP POLICY IF EXISTS "Anyone can read state tokens" ON oauth_states; -CREATE POLICY "Anyone can read state tokens" ON oauth_states - FOR SELECT - USING (true); - --- Allow anyone to update state tokens (for marking as used) -DROP POLICY IF EXISTS "Anyone can update state tokens" ON oauth_states; -CREATE POLICY "Anyone can update state tokens" ON oauth_states - FOR UPDATE - USING (true); - --- Allow anyone to delete expired state tokens (for cleanup) -DROP POLICY IF EXISTS "Anyone can delete expired states" ON oauth_states; -CREATE POLICY "Anyone can delete expired states" ON oauth_states - FOR DELETE - USING (expires_at < NOW()); - -COMMENT ON TABLE oauth_states IS - 'OAuth state tokens are random UUIDs with 5-minute expiration. Safe to allow public access.'; +-- NOTE: the custom `oauth_states` CSRF table + `src/lib/auth/oauth-state.ts` +-- were removed (#183). They had zero production callers — the real OAuth CSRF +-- defense is Supabase's built-in PKCE `state` (see OAuthButtons.tsx / +-- auth/callback). The table also carried fully-public INSERT/SELECT/UPDATE RLS, +-- so it was unused attack surface. Dropped from prod via the Management API. -- ============================================================================ -- PART 4: FUNCTIONS @@ -2321,7 +2277,7 @@ COMMENT ON TABLE group_keys IS 'Encrypted symmetric group keys per member per ve -- Created: -- ✅ Payment tables: payment_intents, payment_results, subscriptions, webhook_events, payment_provider_config -- ✅ Auth tables: user_profiles, auth_audit_logs --- ✅ Security tables: rate_limit_attempts, oauth_states +-- ✅ Security tables: rate_limit_attempts -- ✅ Messaging tables: user_connections, conversations, messages, user_encryption_keys, conversation_keys, typing_indicators -- ✅ Group chat tables: conversation_members, group_keys (Feature 010) -- ✅ Storage buckets: avatars (5MB limit, public read) diff --git a/supabase/migrations/999_drop_all_tables.sql b/supabase/migrations/999_drop_all_tables.sql index 7c3a0ef1..727cfc5f 100644 --- a/supabase/migrations/999_drop_all_tables.sql +++ b/supabase/migrations/999_drop_all_tables.sql @@ -39,6 +39,8 @@ DROP TABLE IF EXISTS payment_provider_config CASCADE; -- Security tables (Feature 017) DROP TABLE IF EXISTS rate_limit_attempts CASCADE; +-- oauth_states removed (#183) — kept here as an idempotent no-op so a teardown +-- run against an OLDER database still clears the dropped table. DROP TABLE IF EXISTS oauth_states CASCADE; -- Messaging tables (PRP-023) - depend on auth.users via foreign keys