How to Set Up Supabase Auth Securely

Share
How-To Guide

How to Set Up Supabase Auth Securely

Complete authentication with RLS integration and social providers

TL;DR

TL;DR (20 minutes): Use the Supabase client for auth, never expose your service role key on the client, always enable RLS on tables that use auth.uid() , configure proper redirect URLs, use PKCE flow for SPAs, and verify sessions server-side for sensitive operations.

Prerequisites

  • A Supabase project (create one at supabase.com)
  • Node.js and npm installed
  • Basic understanding of React, Next.js, or your framework of choice
  • Your Supabase URL and anon key (from Project Settings > API)

Critical Security Note

Your Supabase anon key is designed to be public, but it only provides access to data allowed by your RLS policies. Without proper RLS, anyone with your anon key can read/write all data. Auth and RLS must work together.

Step-by-Step Guide

1

Install and configure the Supabase client

npm install @supabase/supabase-js

Create a Supabase client file (lib/supabase.ts):

import { createClient } from '@supabase/supabase-js';

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

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: true,
    flowType: 'pkce'  // More secure for SPAs
  }
});

Never do this:

// WRONG: Service role key on client exposes full database access
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY);
2

Configure auth settings in Supabase Dashboard

Go to Authentication > URL Configuration and set:

  • Site URL: Your production URL (e.g., https://myapp.com)
  • Redirect URLs: Add all valid redirect destinations
# Add these redirect URLs:
http://localhost:3000/**
https://myapp.com/**
https://staging.myapp.com/**

In Authentication > Settings, configure:

  • Enable email confirmations for production
  • Set secure password requirements (min 8 chars)
  • Configure session lifetime (default 1 week)
3

Implement secure sign-up and sign-in

// Sign up with email and password
async function signUp(email: string, password: string) {
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${window.location.origin}/auth/callback`,
      data: {
        // Optional: Add user metadata
        display_name: 'New User'
      }
    }
  });

  if (error) {
    // Handle specific errors
    if (error.message.includes('already registered')) {
      throw new Error('An account with this email already exists');
    }
    throw error;
  }

  return data;
}

// Sign in with email and password
async function signIn(email: string, password: string) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password
  });

  if (error) {
    // Don't reveal whether email exists
    throw new Error('Invalid email or password');
  }

  return data;
}

// Sign out
async function signOut() {
  const { error } = await supabase.auth.signOut();
  if (error) throw error;
}
4

Handle session state properly

import { useEffect, useState } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase';

export function useAuth() {
  const [user, setUser] = useState<User | null>(null);
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setUser(session?.user ?? null);
      setLoading(false);
    });

    // Listen for auth changes
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      async (event, session) => {
        setSession(session);
        setUser(session?.user ?? null);

        // Handle specific events
        if (event === 'SIGNED_OUT') {
          // Clear any cached data
        }
        if (event === 'TOKEN_REFRESHED') {
          // Session was refreshed
        }
        if (event === 'USER_UPDATED') {
          // User data changed
        }
      }
    );

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

  return { user, session, loading };
}
5

Create auth callback handler

For the PKCE flow, create an auth callback page (app/auth/callback/route.ts for Next.js App Router):

import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

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 = createRouteHandlerClient({ cookies });

    // Exchange code for session
    const { error } = await supabase.auth.exchangeCodeForSession(code);

    if (!error) {
      return NextResponse.redirect(new URL(next, requestUrl.origin));
    }
  }

  // Redirect to error page if code exchange fails
  return NextResponse.redirect(
    new URL('/auth/error?message=Could not authenticate', requestUrl.origin)
  );
}
6

Integrate auth with Row Level Security

Create RLS policies that use auth.uid():

-- Enable RLS on your table
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;

-- Policy: Users can only read their own profile
CREATE POLICY "Users can view own profile"
ON user_profiles FOR SELECT
USING (auth.uid() = id);

-- Policy: Users can update their own profile
CREATE POLICY "Users can update own profile"
ON user_profiles FOR UPDATE
USING (auth.uid() = id);

-- Policy: Auto-create profile on signup (via trigger)
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
  INSERT INTO public.user_profiles (id, email, created_at)
  VALUES (new.id, new.email, now());
  RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

Now your queries automatically filter by user:

// This only returns the current user's data (thanks to RLS)
const { data, error } = await supabase
  .from('user_profiles')
  .select('*')
  .single();
7

Set up social authentication providers

In Supabase Dashboard, go to Authentication > Providers and enable Google:

  1. Create OAuth credentials in Google Cloud Console
  2. Add authorized redirect URI: https://[your-project].supabase.co/auth/v1/callback
  3. Enter Client ID and Secret in Supabase
// Sign in with Google
async function signInWithGoogle() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo: `${window.location.origin}/auth/callback`,
      queryParams: {
        access_type: 'offline',
        prompt: 'consent'
      }
    }
  });

  if (error) throw error;
}

// Sign in with GitHub
async function signInWithGitHub() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'github',
    options: {
      redirectTo: `${window.location.origin}/auth/callback`,
      scopes: 'read:user user:email'
    }
  });

  if (error) throw error;
}
8

Verify sessions server-side for sensitive operations

For API routes or server components, always verify the session:

// Next.js API Route (Pages Router)
import { createPagesServerClient } from '@supabase/auth-helpers-nextjs';

export default async function handler(req, res) {
  const supabase = createPagesServerClient({ req, res });

  // Get and verify session
  const { data: { session }, error } = await supabase.auth.getSession();

  if (!session) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // User is authenticated - session.user contains user data
  const userId = session.user.id;

  // Perform authorized action...
  const { data } = await supabase
    .from('sensitive_data')
    .select('*')
    .eq('user_id', userId);

  return res.json(data);
}

// Next.js Server Component (App Router)
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function ProtectedPage() {
  const supabase = createServerComponentClient({ cookies });
  const { data: { session } } = await supabase.auth.getSession();

  if (!session) {
    redirect('/login');
  }

  return <div>Welcome, {session.user.email}</div>;
}

Security Checklist

  • RLS is enabled on ALL tables that store user data
  • Service role key is NEVER used on the client side
  • Redirect URLs are configured and restricted to your domains
  • Email confirmation is enabled for production
  • PKCE flow is used for single-page applications
  • Sessions are verified server-side for sensitive operations
  • OAuth provider credentials are stored securely
  • Auth triggers handle profile creation securely

How to Verify It Worked

  1. Test sign-up: Create an account, verify email confirmation is sent
  2. Test sign-in: Log in, verify session is created and persisted
  3. Test RLS: Query a table - you should only see your own data
  4. Test as anon: Open DevTools, clear cookies, try to query - should get no data
  5. Test social auth: Sign in with Google/GitHub, verify account linking works
  6. Test sign-out: Log out, verify session is cleared and data is inaccessible

Common Errors & Troubleshooting

Error: "Invalid Refresh Token"

The session expired and couldn't be refreshed. This happens when the refresh token is revoked or expired. Clear local storage and sign in again.

Error: "Email not confirmed"

User signed up but didn't confirm email. Check spam folder, or disable email confirmation in dev (not recommended for production).

Error: "new row violates row-level security policy"

Your RLS policies are blocking the operation. Check that the user is authenticated and the policy allows the action.

OAuth redirect not working

Verify the redirect URL is added to both your OAuth provider config and Supabase URL Configuration. URLs must match exactly including trailing slashes.

Session not persisting after refresh

Ensure persistSession: true is set in client config. For SSR, ensure you're using the correct auth helpers for your framework.

Can I use Supabase Auth without RLS?

Technically yes, but it's extremely dangerous. Without RLS, any authenticated user can access ALL data in your database. Supabase Auth and RLS are designed to work together - auth provides the user identity, RLS enforces what they can access.

How do I add custom claims to the JWT?

Use a database function with auth.jwt() hook. Create a function that returns custom claims based on the user, and configure it in Authentication > Hooks. Be careful not to add sensitive data as JWTs are readable on the client.

Should I use email/password or social auth?

Social auth (OAuth) is generally more secure because users don't create weak passwords and you don't store password hashes. Offer both if possible - social auth for convenience, email/password as fallback.

How do I handle role-based access control?

Store roles in a user_roles table or in user metadata. Reference it in RLS policies using a function or by joining tables. For admin-only features, create specific policies that check the role.

Related guides:Set Up Supabase RLS · RLS Policy Patterns · Test Supabase RLS · Protect Routes & API Endpoints

How-To Guides

How to Set Up Supabase Auth Securely