How to Implement OAuth Login with Supabase in a Single Custom Hook

How to Implement OAuth Login with Supabase in a Single Custom Hook

The Problem

Building OAuth authentication from scratch means handling token storage, session management, provider-specific quirks, PKCE flows, callback routing, and state management—all while keeping it secure and user-friendly. Most developers end up with authentication logic scattered across multiple components, duplicated code for each OAuth provider, and brittle state management that breaks on page refresh. Supabase provides OAuth infrastructure, but the official SDK still requires boilerplate: calling signInWithOAuth, handling redirects, managing loading states, error handling, and syncing auth state across components. The real challenge is encapsulating all this complexity into a single, reusable hook that works consistently across Google, GitHub, Discord, and other providers while handling edge cases like redirect loops, failed callbacks, and session persistence. This integration delivers exactly that: one custom hook that handles complete OAuth flows with consistent error handling, automatic session recovery, and provider-agnostic implementation.

Tech Stack & Prerequisites

  • Node.js v20+ and npm/pnpm
  • React v18+ or Next.js 14+
  • TypeScript 5+
  • Supabase Account (free tier available)
  • @supabase/supabase-js v2.39+
  • @supabase/auth-ui-react v0.4+ (optional for pre-built UI)
  • OAuth Provider Credentials (Google, GitHub, Discord, etc.)

Step-by-Step Implementation

Step 1: Setup

Option A: Next.js Setup

bash
npx create-next-app@latest supabase-oauth-hook --typescript --app --tailwind
cd supabase-oauth-hook
npm install @supabase/supabase-js @supabase/auth-helpers-nextjs

Option B: Vite + React Setup

bash
npm create vite@latest supabase-oauth-hook -- --template react-ts
cd supabase-oauth-hook
npm install
npm install @supabase/supabase-js

Create project structure:

bash
mkdir -p lib/supabase hooks components
touch lib/supabase/client.ts hooks/useOAuth.ts components/AuthButton.tsx

Step 2: Configuration

2.1: Create Supabase Project

  1. Go to supabase.com
  2. Create a new project
  3. Note your project URL and anon key from Settings → API

2.2: Configure OAuth Providers

For Google OAuth:

  1. Go to Google Cloud Console
  2. Create OAuth 2.0 credentials
  3. Add authorized redirect URI: https://your-project-ref.supabase.co/auth/v1/callback
  4. Copy Client ID and Client Secret

For GitHub OAuth:

  1. Go to GitHub Settings → Developer settings → OAuth Apps
  2. Create new OAuth app
  3. Authorization callback URL: https://your-project-ref.supabase.co/auth/v1/callback
  4. Copy Client ID and Client Secret

For Discord OAuth:

  1. Go to Discord Developer Portal
  2. Create application → OAuth2
  3. Add redirect: https://your-project-ref.supabase.co/auth/v1/callback
  4. Copy Client ID and Client Secret

2.3: Add Providers to Supabase

In Supabase Dashboard → Authentication → Providers:

  1. Enable Google, GitHub, Discord, or other providers
  2. Paste Client ID and Client Secret for each provider
  3. Configure redirect URLs

2.4: Environment Variables

Create .env.local (Next.js) or .env (Vite):

bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

# Optional: Specify redirect URL for OAuth callbacks
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Add to .gitignore:

bash
echo ".env.local" >> .gitignore
echo ".env" >> .gitignore

Step 3: Core Logic

3.1: Supabase Client

Create lib/supabase/client.ts:

typescript
// lib/supabase/client.ts
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

if (!supabaseUrl || !supabaseAnonKey) {
  throw new Error('Missing Supabase environment variables');
}

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: true,
  },
});

3.2: OAuth Custom Hook

Create hooks/useOAuth.ts:

typescript
// hooks/useOAuth.ts
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/lib/supabase/client';
import type { Provider, Session, AuthError } from '@supabase/supabase-js';

export interface OAuthOptions {
  redirectTo?: string;
  scopes?: string;
  queryParams?: Record<string, string>;
}

export interface UseOAuthResult {
  // State
  session: Session | null;
  user: Session['user'] | null;
  loading: boolean;
  error: AuthError | null;

  // Actions
  signInWithProvider: (provider: Provider, options?: OAuthOptions) => Promise<void>;
  signOut: () => Promise<void>;
  
  // Status checks
  isAuthenticated: boolean;
  isLoading: boolean;
}

export function useOAuth(): UseOAuthResult {
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<AuthError | null>(null);

  /**
   * Initialize session on mount
   */
  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session }, error }) => {
      if (error) {
        console.error('Session fetch error:', error);
        setError(error);
      }
      setSession(session);
      setLoading(false);
    });

    // Listen for auth state changes
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session);
      setLoading(false);
      setError(null);
    });

    return () => subscription.unsubscribe();
  }, []);

  /**
   * Sign in with OAuth provider
   */
  const signInWithProvider = useCallback(
    async (provider: Provider, options: OAuthOptions = {}) => {
      try {
        setLoading(true);
        setError(null);

        const { error } = await supabase.auth.signInWithOAuth({
          provider,
          options: {
            redirectTo: options.redirectTo || 
              `${window.location.origin}/auth/callback`,
            scopes: options.scopes,
            queryParams: options.queryParams,
          },
        });

        if (error) {
          throw error;
        }

        // Note: User will be redirected, so this won't execute
        // Loading state will be handled by redirect
      } catch (err) {
        console.error('OAuth sign-in error:', err);
        setError(err as AuthError);
        setLoading(false);
      }
    },
    []
  );

  /**
   * Sign out current user
   */
  const signOut = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);

      const { error } = await supabase.auth.signOut();

      if (error) {
        throw error;
      }

      setSession(null);
    } catch (err) {
      console.error('Sign-out error:', err);
      setError(err as AuthError);
    } finally {
      setLoading(false);
    }
  }, []);

  return {
    session,
    user: session?.user ?? null,
    loading,
    error,
    signInWithProvider,
    signOut,
    isAuthenticated: !!session,
    isLoading: loading,
  };
}

3.3: Enhanced Hook with Provider-Specific Options

Create hooks/useOAuthAdvanced.ts:

typescript
// hooks/useOAuthAdvanced.ts
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/lib/supabase/client';
import type { Provider, Session, AuthError } from '@supabase/supabase-js';

export interface ProviderConfig {
  scopes?: string;
  queryParams?: Record<string, string>;
}

export interface ProviderConfigs {
  google?: ProviderConfig;
  github?: ProviderConfig;
  discord?: ProviderConfig;
  [key: string]: ProviderConfig | undefined;
}

export interface UseOAuthAdvancedOptions {
  redirectTo?: string;
  onSuccess?: (session: Session) => void;
  onError?: (error: AuthError) => void;
  providerConfigs?: ProviderConfigs;
}

export interface UseOAuthAdvancedResult {
  session: Session | null;
  user: Session['user'] | null;
  loading: boolean;
  error: AuthError | null;
  signInWithProvider: (provider: Provider) => Promise<void>;
  signOut: () => Promise<void>;
  isAuthenticated: boolean;
  isLoading: boolean;
  refreshSession: () => Promise<void>;
}

export function useOAuthAdvanced(
  options: UseOAuthAdvancedOptions = {}
): UseOAuthAdvancedResult {
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<AuthError | null>(null);

  const {
    redirectTo,
    onSuccess,
    onError,
    providerConfigs = {},
  } = options;

  /**
   * Get provider-specific configuration
   */
  const getProviderConfig = useCallback(
    (provider: Provider): ProviderConfig => {
      return providerConfigs[provider] || {};
    },
    [providerConfigs]
  );

  /**
   * Initialize and listen to auth state
   */
  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session }, error }) => {
      if (error) {
        console.error('Session fetch error:', error);
        setError(error);
        onError?.(error);
      } else if (session) {
        setSession(session);
        onSuccess?.(session);
      }
      setLoading(false);
    });

    // Listen for auth state changes
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      console.log('Auth state changed:', event);

      setSession(session);
      setLoading(false);

      if (event === 'SIGNED_IN' && session) {
        onSuccess?.(session);
      } else if (event === 'SIGNED_OUT') {
        setSession(null);
      }

      setError(null);
    });

    return () => subscription.unsubscribe();
  }, [onSuccess, onError]);

  /**
   * Sign in with OAuth provider
   */
  const signInWithProvider = useCallback(
    async (provider: Provider) => {
      try {
        setLoading(true);
        setError(null);

        const config = getProviderConfig(provider);

        const { error } = await supabase.auth.signInWithOAuth({
          provider,
          options: {
            redirectTo: redirectTo || `${window.location.origin}/auth/callback`,
            scopes: config.scopes,
            queryParams: config.queryParams,
          },
        });

        if (error) {
          throw error;
        }
      } catch (err) {
        const authError = err as AuthError;
        console.error('OAuth sign-in error:', authError);
        setError(authError);
        setLoading(false);
        onError?.(authError);
      }
    },
    [getProviderConfig, redirectTo, onError]
  );

  /**
   * Sign out current user
   */
  const signOut = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);

      const { error } = await supabase.auth.signOut();

      if (error) {
        throw error;
      }

      setSession(null);
    } catch (err) {
      const authError = err as AuthError;
      console.error('Sign-out error:', authError);
      setError(authError);
      onError?.(authError);
    } finally {
      setLoading(false);
    }
  }, [onError]);

  /**
   * Manually refresh session
   */
  const refreshSession = useCallback(async () => {
    try {
      setLoading(true);
      const { data, error } = await supabase.auth.refreshSession();

      if (error) {
        throw error;
      }

      if (data.session) {
        setSession(data.session);
        onSuccess?.(data.session);
      }
    } catch (err) {
      const authError = err as AuthError;
      console.error('Session refresh error:', authError);
      setError(authError);
      onError?.(authError);
    } finally {
      setLoading(false);
    }
  }, [onSuccess, onError]);

  return {
    session,
    user: session?.user ?? null,
    loading,
    error,
    signInWithProvider,
    signOut,
    isAuthenticated: !!session,
    isLoading: loading,
    refreshSession,
  };
}

3.4: Auth Button Component

Create components/AuthButton.tsx:

typescript
// components/AuthButton.tsx
'use client';

import { useOAuth } from '@/hooks/useOAuth';
import type { Provider } from '@supabase/supabase-js';

interface AuthButtonProps {
  provider: Provider;
  children?: React.ReactNode;
  className?: string;
}

export function AuthButton({ provider, children, className = '' }: AuthButtonProps) {
  const { signInWithProvider, loading } = useOAuth();

  const defaultLabels: Record<string, string> = {
    google: 'Continue with Google',
    github: 'Continue with GitHub',
    discord: 'Continue with Discord',
    facebook: 'Continue with Facebook',
    twitter: 'Continue with Twitter',
  };

  const handleSignIn = async () => {
    await signInWithProvider(provider);
  };

  return (
    <button
      onClick={handleSignIn}
      disabled={loading}
      className={`px-4 py-2 rounded font-medium transition disabled:opacity-50 ${className}`}
    >
      {loading ? 'Signing in...' : (children || defaultLabels[provider] || `Sign in with ${provider}`)}
    </button>
  );
}

3.5: Auth Callback Handler (Next.js)

Create app/auth/callback/route.ts:

typescript
// app/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';

export async function GET(request: NextRequest) {
  const requestUrl = new URL(request.url);
  const code = requestUrl.searchParams.get('code');
  const next = requestUrl.searchParams.get('next') || '/dashboard';

  if (code) {
    const supabase = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    );

    const { error } = await supabase.auth.exchangeCodeForSession(code);

    if (error) {
      console.error('OAuth callback error:', error);
      return NextResponse.redirect(new URL('/auth/error', request.url));
    }
  }

  // Redirect to the original page or dashboard
  return NextResponse.redirect(new URL(next, request.url));
}

3.6: Protected Route Component

Create components/ProtectedRoute.tsx:

typescript
// components/ProtectedRoute.tsx
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useOAuth } from '@/hooks/useOAuth';

interface ProtectedRouteProps {
  children: React.ReactNode;
  redirectTo?: string;
}

export function ProtectedRoute({ 
  children, 
  redirectTo = '/login' 
}: ProtectedRouteProps) {
  const { isAuthenticated, isLoading } = useOAuth();
  const router = useRouter();

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push(redirectTo);
    }
  }, [isAuthenticated, isLoading, redirectTo, router]);

  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
      </div>
    );
  }

  if (!isAuthenticated) {
    return null;
  }

  return <>{children}</>;
}

3.7: Login Page

Create app/login/page.tsx:

typescript
// app/login/page.tsx
'use client';

import { useOAuth } from '@/hooks/useOAuth';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function LoginPage() {
  const { signInWithProvider, isAuthenticated, isLoading, error } = useOAuth();
  const router = useRouter();

  useEffect(() => {
    if (isAuthenticated) {
      router.push('/dashboard');
    }
  }, [isAuthenticated, router]);

  const providers = [
    { name: 'google', label: 'Google', icon: '🔍', color: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50' },
    { name: 'github', label: 'GitHub', icon: '🐙', color: 'bg-gray-900 text-white hover:bg-gray-800' },
    { name: 'discord', label: 'Discord', icon: '💬', color: 'bg-indigo-600 text-white hover:bg-indigo-700' },
  ];

  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
      </div>
    );
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
      <div className="max-w-md w-full space-y-8">
        <div className="text-center">
          <h2 className="text-3xl font-bold text-gray-900">Welcome</h2>
          <p className="mt-2 text-sm text-gray-600">
            Sign in to your account
          </p>
        </div>

        {error && (
          <div className="p-4 bg-red-50 border border-red-200 rounded">
            <p className="text-sm text-red-800">{error.message}</p>
          </div>
        )}

        <div className="space-y-3">
          {providers.map((provider) => (
            <button
              key={provider.name}
              onClick={() => signInWithProvider(provider.name as any)}
              disabled={isLoading}
              className={`w-full flex items-center justify-center px-4 py-3 border rounded-md shadow-sm text-sm font-medium transition ${provider.color}`}
            >
              <span className="mr-2">{provider.icon}</span>
              Continue with {provider.label}
            </button>
          ))}
        </div>

        <div className="mt-6 text-center">
          <p className="text-xs text-gray-500">
            By signing in, you agree to our Terms of Service and Privacy Policy
          </p>
        </div>
      </div>
    </div>
  );
}

3.8: Dashboard Page

Create app/dashboard/page.tsx:

typescript
// app/dashboard/page.tsx
'use client';

import { useOAuth } from '@/hooks/useOAuth';
import { ProtectedRoute } from '@/components/ProtectedRoute';

export default function DashboardPage() {
  const { user, signOut, isLoading } = useOAuth();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <ProtectedRoute>
      <div className="min-h-screen bg-gray-50 py-12">
        <div className="max-w-4xl mx-auto px-4">
          <div className="bg-white rounded-lg shadow p-6">
            <div className="flex items-center justify-between mb-6">
              <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
              <button
                onClick={signOut}
                className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition"
              >
                Sign Out
              </button>
            </div>

            <div className="space-y-4">
              <div>
                <h2 className="text-lg font-semibold mb-2">Profile Information</h2>
                <div className="grid grid-cols-2 gap-4">
                  <div>
                    <p className="text-sm text-gray-600">Email</p>
                    <p className="font-medium">{user?.email}</p>
                  </div>
                  <div>
                    <p className="text-sm text-gray-600">User ID</p>
                    <p className="font-mono text-sm">{user?.id}</p>
                  </div>
                  <div>
                    <p className="text-sm text-gray-600">Provider</p>
                    <p className="font-medium capitalize">
                      {user?.app_metadata?.provider || 'N/A'}
                    </p>
                  </div>
                  <div>
                    <p className="text-sm text-gray-600">Created At</p>
                    <p className="text-sm">
                      {user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
                    </p>
                  </div>
                </div>
              </div>

              {user?.user_metadata && Object.keys(user.user_metadata).length > 0 && (
                <div>
                  <h2 className="text-lg font-semibold mb-2">Additional Data</h2>
                  <pre className="bg-gray-50 p-4 rounded text-sm overflow-auto">
                    {JSON.stringify(user.user_metadata, null, 2)}
                  </pre>
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
    </ProtectedRoute>
  );
}

Step 4: Testing

4.1: Start Development Server

bash
npm run dev

Visit http://localhost:3000/login

4.2: Test OAuth Flow

Step 1: Click Provider Button

  • Click “Continue with Google” (or other provider)
  • You’ll be redirected to provider’s login page

Step 2: Authorize

  • Sign in to your provider account
  • Authorize the application
  • You’ll be redirected back to /auth/callback

Step 3: Verify Session

  • Should redirect to /dashboard
  • Check user information is displayed
  • Verify session persists on page refresh

4.3: Test Hook Directly

Create a test component:

typescript
// components/AuthTest.tsx
'use client';

import { useOAuth } from '@/hooks/useOAuth';

export function AuthTest() {
  const { session, user, loading, error, isAuthenticated } = useOAuth();

  return (
    <div className="p-4 bg-gray-100 rounded">
      <h3 className="font-bold mb-2">Auth State:</h3>
      <pre className="text-xs">
        {JSON.stringify(
          {
            isAuthenticated,
            loading,
            userId: user?.id,
            email: user?.email,
            error: error?.message,
          },
          null,
          2
        )}
      </pre>
    </div>
  );
}

4.4: Test Sign Out

In dashboard:

  1. Click “Sign Out” button
  2. Should redirect to login page
  3. Session should be cleared
  4. Attempting to access /dashboard should redirect to /login

4.5: Test Session Persistence

  1. Sign in successfully
  2. Refresh the page
  3. Should remain authenticated
  4. Close and reopen browser
  5. Should still be authenticated (session persisted in localStorage)

4.6: Test Multiple Providers

Test each provider separately:

bash
# Open different browser profiles or use incognito
# Test Google
# Test GitHub  
# Test Discord
```

Verify each provider returns correct user metadata.

## Common Errors & Troubleshooting

### 1. **Error: "Invalid redirect URL" or callback fails**

**Cause:** Redirect URL in Supabase doesn't match the actual callback URL, or wildcard patterns are misconfigured.

**Fix:** Ensure exact URL match in Supabase Dashboard:

In Supabase Dashboard → Authentication → URL Configuration:
```
Site URL: http://localhost:3000
Redirect URLs: 
  http://localhost:3000/auth/callback
  https://yourdomain.com/auth/callback

For Next.js, ensure callback route exists:

typescript
// Verify app/auth/callback/route.ts exists and exports GET handler
export async function GET(request: NextRequest) {
  // Must handle code exchange
  const code = requestUrl.searchParams.get('code');
  
  if (!code) {
    return NextResponse.redirect(new URL('/auth/error?message=no_code', request.url));
  }
  
  // ... rest of handler
}
```

For development with multiple ports:
```
http://localhost:3000/auth/callback
http://localhost:3001/auth/callback
http://127.0.0.1:3000/auth/callback

2. Error: “Session not persisting” or user logged out on refresh

Cause: Supabase client not configured for session persistence, or localStorage is blocked/cleared.

Fix: Ensure proper client configuration and storage:

typescript
// lib/supabase/client.ts
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    autoRefreshToken: true, // CRITICAL: Auto-refresh tokens
    persistSession: true,   // CRITICAL: Persist to localStorage
    detectSessionInUrl: true, // Detect session from URL params
    storage: typeof window !== 'undefined' ? window.localStorage : undefined,
  },
});

For Next.js with SSR, use separate clients:

typescript
// lib/supabase/server.ts (for Server Components)
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export function createClient() {
  const cookieStore = cookies();
  
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
      },
    }
  );
}

Check for storage blockers:

typescript
// In useOAuth hook, add storage check
useEffect(() => {
  // Test localStorage availability
  try {
    localStorage.setItem('test', 'test');
    localStorage.removeItem('test');
  } catch (e) {
    console.error('localStorage not available:', e);
    setError({ message: 'Please enable cookies and local storage' } as AuthError);
  }
}, []);

3. Error: Provider-specific errors (e.g., “invalid_scope” for Google)

Cause: Requesting scopes that aren’t enabled in the OAuth provider’s configuration, or provider requires specific parameters.

Fix: Use provider-specific configurations:

typescript
// For Google - request additional scopes
await signInWithProvider('google', {
  scopes: 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
});

// For GitHub - request specific permissions
await signInWithProvider('github', {
  scopes: 'read:user user:email',
});

// For Discord - request guilds
await signInWithProvider('discord', {
  scopes: 'identify email guilds',
});

Create provider configurations in advanced hook:

typescript
const { signInWithProvider } = useOAuthAdvanced({
  providerConfigs: {
    google: {
      queryParams: {
        access_type: 'offline', // Request refresh token
        prompt: 'consent',      // Force consent screen
      },
    },
    github: {
      scopes: 'read:user user:email',
    },
    discord: {
      queryParams: {
        permissions: '0', // Discord-specific permissions
      },
    },
  },
});

Verify provider credentials in Supabase:

typescript
// Test OAuth config
const testOAuthConfig = async (provider: string) => {
  try {
    const { data, error } = await supabase.auth.signInWithOAuth({
      provider: provider as Provider,
      options: {
        redirectTo: window.location.origin + '/test',
        skipBrowserRedirect: true, // Don't redirect, just test config
      },
    });
    
    console.log(`${provider} config:`, data);
    if (error) console.error(`${provider} error:`, error);
  } catch (e) {
    console.error(`${provider} failed:`, e);
  }
};

Security Checklist

  • Use HTTPS in production – OAuth requires secure transport for tokens
  • Validate redirect URLs – Only allow whitelisted callback URLs in Supabase
  • Never expose service role key – Use anon key only in client-side code
  • Implement PKCE flow – Supabase handles this automatically, ensure it’s not disabled
  • Set short token expiration – Configure in Supabase Dashboard → Authentication → Settings
  • Implement token refresh – Hook already handles this via autoRefreshToken: true
  • Validate session server-side – Don’t trust client session, verify with Supabase on server
  • Use secure cookie settings in production (httpOnly, secure, sameSite)
  • Implement rate limiting on auth endpoints to prevent abuse
  • Log authentication events for security monitoring
  • Revoke tokens on sign-out – Hook calls signOut() which revokes tokens
  • Store sensitive data server-side – Never store secrets in localStorage
  • Implement CORS properly – Restrict origins to your domains only
  • Use state parameter – Supabase handles CSRF protection automatically
  • Monitor for suspicious activity – Track failed login attempts
  • Implement session timeout for inactive users
  • Validate user email – Check email_verified field before granting access
  • Use Row Level Security – Protect Supabase data with RLS policies
  • Rotate OAuth secrets regularly in provider dashboards

Related Resources

For complete authentication infrastructure with multi-factor authentication, see our guide on implementing MFA with Auth0 and NextAuth covering TOTP and SMS verification.

When building secure API endpoints that require authenticated requests, check our tutorial on securing APIs with OAuth 2.0 and JWT for token validation patterns.

For implementing role-based access control after authentication, explore our Discord + Stripe integration guide covering membership tier automation.

When designing scalable user management systems, read our article on designing REST APIs for SaaS applications with authentication best practices.

For teams comparing authentication providers, our Auth0 SSO implementation guide helps evaluate enterprise OAuth solutions.

Leave a Comment

Your email address will not be published. Required fields are marked *

banner
Scroll to Top