# How to Build an Authentication System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable free tier and above
- Last updated: April 2026

## TL;DR

Build a complete Supabase Auth system in Lovable covering email and password, magic links, Google OAuth, and a profiles table auto-created on signup via a database trigger. A Form with Tabs switches between sign-in modes. Protected routes redirect unauthenticated users. The whole system works in production — OAuth breaks in the Lovable preview iframe.

## Before you start

- Supabase project with Auth enabled — it is enabled by default on all new projects
- Email provider configured in Supabase Auth settings (Supabase's built-in email works for development)
- For Google OAuth: a Google Cloud project with OAuth 2.0 credentials created
- Your published Lovable URL added to Supabase Auth → URL Configuration → Redirect URLs
- Basic understanding of how sessions and JWTs work is helpful but not required

## Step-by-step guide

### 1. Create the profiles table with auto-creation trigger

Prompt Lovable to create the profiles table and the trigger that populates it automatically when a new user signs up. This runs entirely in the database — the frontend never manually creates profiles.

```
Create a profiles table in Supabase and a trigger that auto-creates a profile row when a user signs up.

Profiles table:
  id uuid primary key references auth.users(id) on delete cascade
  email text not null
  full_name text
  avatar_url text
  bio text
  updated_at timestamptz default now()
  created_at timestamptz default now()

RLS:
  Enable RLS on profiles.
  SELECT: authenticated users can read all profiles.
  UPDATE: users can only update their own profile (auth.uid() = id).
  INSERT: disallow direct INSERT — the trigger handles creation only.
  DELETE: disallow — profile is deleted via CASCADE when auth.users row is deleted.

Trigger function (runs AFTER INSERT on auth.users):
  CREATE OR REPLACE FUNCTION public.handle_new_user()
  RETURNS trigger
  LANGUAGE plpgsql
  SECURITY DEFINER SET search_path = public
  AS $$
  BEGIN
    INSERT INTO public.profiles (id, email, full_name, avatar_url)
    VALUES (
      NEW.id,
      NEW.email,
      COALESCE(NEW.raw_user_meta_data->>'full_name', split_part(NEW.email, '@', 1)),
      NEW.raw_user_meta_data->>'avatar_url'
    );
    RETURN NEW;
  END;
  $$;

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

Generate TypeScript types for the profiles table.
```

> Pro tip: The trigger uses SECURITY DEFINER so it can insert into profiles even though direct INSERT is blocked by RLS. The SET search_path = public prevents search path injection attacks.

**Expected result:** The profiles table is created with RLS. The trigger exists. A new user signup automatically creates a profiles row. TypeScript types are generated.

### 2. Build the auth form with Tabs for all sign-in modes

Create the main authentication page with three tabs: Sign In (email + password), Sign Up (email + password + name), and Magic Link (email only). All three use react-hook-form with Zod validation.

```
Build an authentication page at src/pages/Auth.tsx.

Layout: centered Card with a logo at the top. Three Tabs: 'Sign In', 'Sign Up', 'Magic Link'.

Sign In tab:
  - Email Input (type='email', required)
  - Password Input (type='password', required, min 8 chars)
  - 'Sign In' Button (full width, loading state while submitting)
  - Call supabase.auth.signInWithPassword({ email, password })
  - On success: navigate('/dashboard')
  - On error: show Alert with error.message

Sign Up tab:
  - Full Name Input (required, min 2 chars)
  - Email Input (required, valid email)
  - Password Input (required, min 8 chars, zod refine: at least one number)
  - Confirm Password Input (must match password)
  - 'Create Account' Button (loading state)
  - Call supabase.auth.signUp({ email, password, options: { data: { full_name: name } } })
  - On success: show Alert 'Check your email to confirm your account'
  - On error: show Alert with error.message

Magic Link tab:
  - Email Input (required)
  - 'Send Magic Link' Button (loading state)
  - Call supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: window.location.origin + '/dashboard' } })
  - On success: show success Alert 'Magic link sent — check your inbox'

Below all tabs: a Separator with 'or' text, then a Google sign-in Button calling supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: window.location.origin + '/dashboard' } })

Note: OAuth will not work in the Lovable preview. Test it on the published URL.
```

> Pro tip: Store the intended destination route before redirecting to /auth in localStorage (key: 'auth_redirect'). After successful sign-in, navigate to that stored route and clear it. This preserves the URL the user was trying to access.

**Expected result:** The auth page renders with three tabs. Email/password sign-in and sign-up work in the preview. Magic link sends an email. Google OAuth works only on the published URL.

### 3. Create the auth context and session listener

Build a React context that makes the current user and session available throughout the app. It listens to Supabase auth state changes so all components react to sign-in and sign-out automatically.

```
// src/contexts/AuthContext.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { Session, User } from '@supabase/supabase-js'
import { supabase } from '@/integrations/supabase/client'

type Profile = {
  id: string
  email: string
  full_name: string | null
  avatar_url: string | null
  bio: string | null
}

type AuthContextType = {
  user: User | null
  session: Session | null
  profile: Profile | null
  loading: boolean
  signOut: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [session, setSession] = useState<Session | null>(null)
  const [profile, setProfile] = useState<Profile | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session)
      setUser(session?.user ?? null)
      if (session?.user) fetchProfile(session.user.id)
      else setLoading(false)
    })

    const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session)
      setUser(session?.user ?? null)
      if (session?.user) fetchProfile(session.user.id)
      else { setProfile(null); setLoading(false) }
    })

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

  async function fetchProfile(userId: string) {
    const { data } = await supabase
      .from('profiles')
      .select('id, email, full_name, avatar_url, bio')
      .eq('id', userId)
      .single()
    setProfile(data)
    setLoading(false)
  }

  const signOut = async () => {
    await supabase.auth.signOut()
  }

  return (
    <AuthContext.Provider value={{ user, session, profile, loading, signOut }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
  return ctx
}
```

> Pro tip: Wrap the entire app with <AuthProvider> in src/App.tsx before any Router components. This ensures the auth state is available everywhere including route guards.

**Expected result:** All components can call useAuth() to get the current user, session, and profile. Auth state updates automatically when the user signs in or out.

### 4. Add protected route wrapper

Create a ProtectedRoute component that checks auth state and redirects unauthenticated users to /auth. Wrap all private pages with this component in your router.

```
Build a ProtectedRoute component and update the app router.

Create src/components/ProtectedRoute.tsx:
- Import useAuth from AuthContext
- If loading is true, return a centered Spinner (shadcn/ui skeleton or Loader2 icon from lucide-react)
- If user is null, return <Navigate to='/auth' replace />
- Otherwise return <>{children}</>

Update src/App.tsx to use ProtectedRoute:
- Wrap /dashboard, /profile, /settings, and any other private routes with <ProtectedRoute>
- /auth and / (landing page) remain unprotected
- Add the AuthProvider wrapping the entire BrowserRouter

Example router structure:
<AuthProvider>
  <BrowserRouter>
    <Routes>
      <Route path='/' element={<Landing />} />
      <Route path='/auth' element={<Auth />} />
      <Route path='/dashboard' element={
        <ProtectedRoute>
          <Dashboard />
        </ProtectedRoute>
      } />
      <Route path='/profile' element={
        <ProtectedRoute>
          <Profile />
        </ProtectedRoute>
      } />
    </Routes>
  </BrowserRouter>
</AuthProvider>
```

> Pro tip: Add a <Navigate to='/dashboard' replace /> guard inside the Auth page: if the user is already signed in when they visit /auth, redirect them to /dashboard immediately. No one should see the auth form while already logged in.

**Expected result:** Navigating to /dashboard while signed out redirects to /auth. After sign-in, the user lands on /dashboard. Already-signed-in users visiting /auth are redirected to /dashboard.

### 5. Build the profile settings page

Create the profile settings page where users update their name, bio, and avatar. Avatar upload uses Supabase Storage.

```
Build a profile settings page at src/pages/ProfileSettings.tsx.

Requirements:
- Pre-populate the form with the current user's profile data from useAuth().profile
- Form fields (react-hook-form + zod):
  - Full Name (required, min 2 chars, max 100)
  - Bio (optional, max 300 chars, Textarea with character count)
  - Avatar: show current avatar_url in an Avatar component, with a 'Change Photo' Button that opens a file input
- Avatar upload logic:
  1. User selects an image file (accept='image/*', max 2MB validation)
  2. Upload to Supabase Storage bucket 'avatars' at path: userId/avatar.{ext}
  3. Get the public URL: supabase.storage.from('avatars').getPublicUrl(path)
  4. Store the URL in the avatar_url field
- Save button: call supabase.from('profiles').update({ full_name, bio, avatar_url, updated_at: new Date().toISOString() }).eq('id', user.id)
- Show a success toast: 'Profile updated'
- Show loading state on the save button during submission
- Create the 'avatars' Supabase Storage bucket with public access and a policy: authenticated users can upload to their own userId/ path
```

> Pro tip: Before uploading, check the file size and show a validation error if it exceeds 2MB. Also convert the image to JPEG before upload using a canvas element — this keeps avatar storage costs low and load times fast.

**Expected result:** The profile page pre-fills with user data. Uploading a new avatar shows the preview immediately. Saving updates both the database and the AuthContext profile state.

## Complete code example

File: `src/contexts/AuthContext.tsx`

```typescript
import { createContext, useContext, useEffect, useState } from 'react'
import { Session, User } from '@supabase/supabase-js'
import { supabase } from '@/integrations/supabase/client'

type Profile = {
  id: string
  email: string
  full_name: string | null
  avatar_url: string | null
  bio: string | null
}

type AuthContextType = {
  user: User | null
  session: Session | null
  profile: Profile | null
  loading: boolean
  signOut: () => Promise<void>
  refreshProfile: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [session, setSession] = useState<Session | null>(null)
  const [profile, setProfile] = useState<Profile | null>(null)
  const [loading, setLoading] = useState(true)

  const fetchProfile = async (userId: string) => {
    const { data } = await supabase
      .from('profiles')
      .select('id, email, full_name, avatar_url, bio')
      .eq('id', userId)
      .single()
    setProfile(data)
    setLoading(false)
  }

  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session)
      setUser(session?.user ?? null)
      if (session?.user) fetchProfile(session.user.id)
      else setLoading(false)
    })

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setSession(session)
        setUser(session?.user ?? null)
        if (session?.user) fetchProfile(session.user.id)
        else { setProfile(null); setLoading(false) }
      }
    )

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

  const signOut = async () => {
    await supabase.auth.signOut()
  }

  const refreshProfile = async () => {
    if (user) await fetchProfile(user.id)
  }

  return (
    <AuthContext.Provider value={{ user, session, profile, loading, signOut, refreshProfile }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
  return ctx
}
```

## Common mistakes

- **Testing Google OAuth in the Lovable preview iframe** — Google OAuth blocks requests from iframes for security reasons. The sign-in popup fails with an error about the origin not being allowed. Fix: Always test OAuth on the published Lovable URL (the .lovable.app domain) or your deployed custom domain. Add both URLs to Supabase Auth → URL Configuration → Redirect URLs and to your Google OAuth app's authorized redirect URIs.
- **Reading from auth.users directly in frontend code** — auth.users is protected and not accessible via the anon key. Queries against it return nothing or throw a permission error. Fix: Use the profiles table for all user data reads. The trigger ensures profiles always has a row for every auth.users row. For admin operations (listing all users), use a service-role Edge Function.
- **Not adding emailRedirectTo in signInWithOtp** — Without a redirect URL, magic links redirect to localhost, which doesn't work in production. Users click the link and get a blank page or a 404. Fix: Always pass options: { emailRedirectTo: window.location.origin + '/dashboard' } in signInWithOtp. Also add the URL to Supabase Auth → URL Configuration → Redirect URLs.
- **Forgetting to handle the SIGNED_IN event after magic link click** — When a user clicks a magic link, Supabase redirects to your app with a token in the URL fragment. The onAuthStateChange listener processes this — but only if it's mounted before the redirect completes. Fix: Mount the AuthProvider (and therefore the onAuthStateChange listener) at the root of your app, before routing. The listener processes the token fragment on initial load.
- **Showing the sign-in form briefly before redirecting already-authenticated users** — The auth state loads asynchronously. If the Auth page doesn't check loading state, authenticated users see the sign-in form flash before being redirected. Fix: In the Auth page, check useAuth().loading first and show a Skeleton or null while loading. Only render the form or redirect once loading is false.

## Best practices

- Use onAuthStateChange as the single source of truth for session state — never manage tokens manually
- Store only non-sensitive display data in profiles — never store payment info, passwords, or private tokens in that table
- Set up Supabase email templates (Auth → Email Templates) with your brand before launching — the default templates look generic
- Enable email confirmation for sign-ups in production — this prevents fake accounts and reduces spam
- Test every redirect URL scenario: sign-up confirmation, magic link, OAuth, and password reset all send emails with different link patterns
- Add a rate limit on the auth page by disabling the submit button for 30 seconds after a failed attempt — Supabase rate-limits its own auth API but frontend debouncing improves UX
- Never store the session token in localStorage manually — Supabase handles session persistence automatically and securely
- Audit your RLS policies on profiles: users should update only their own row, and some fields (like is_admin) should be update-protected for all client roles

## Frequently asked questions

### Why does Google sign-in work on the published URL but not in the Lovable preview?

Google OAuth blocks sign-in from iframes and from origins not listed in your authorized redirect URIs. The Lovable preview runs in an iframe on a different origin. Always test OAuth on the published Lovable URL (or your deployed domain) and add that URL to both Supabase Auth settings and your Google OAuth app's authorized redirect URIs.

### How do I add password reset?

Call supabase.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + '/auth/update-password' }). This sends a reset link. Create an /auth/update-password route that calls supabase.auth.updateUser({ password: newPassword }) — Supabase automatically sets the session from the reset token in the URL.

### Can users sign in with both email/password and Google using the same email address?

By default, Supabase creates separate accounts for each provider even if the email is the same. Enable 'Link users across providers' in Supabase Auth settings to merge accounts with the same email. With this enabled, signing in with Google on an email that has an email/password account links them into one user.

### What's in raw_user_meta_data and how do I use it?

raw_user_meta_data contains the data you pass in signUp's options.data, plus OAuth provider data like the user's name and profile picture from Google. The trigger uses COALESCE(NEW.raw_user_meta_data->>'full_name', ...) to populate the profiles table from this data on signup.

### How do I handle session expiry?

Supabase access tokens expire after one hour by default. The Supabase client automatically refreshes the session using the refresh token — you don't need to handle this manually. If the refresh fails (refresh token expired or revoked), onAuthStateChange fires a SIGNED_OUT event and your ProtectedRoute redirects to /auth.

### How do I sign out from all devices?

Call supabase.auth.signOut({ scope: 'global' }) instead of the default 'local' scope. This invalidates all refresh tokens for the user across all sessions. Use this for security-sensitive sign-out scenarios like account compromise.

### Can I restrict sign-ups to specific email domains?

Yes, in two ways. Option 1: in Supabase Auth settings, add allowed email domains (only users with those domains can sign up). Option 2: add a CHECK constraint on the profiles table or a trigger that validates the email domain and raises an exception if it doesn't match your allowed list.

### Do I need to handle token refresh manually?

No. The Supabase JavaScript client handles token refresh automatically using the stored refresh token. As long as you initialize the client once (which Lovable does via the generated supabase/client.ts) and use onAuthStateChange, sessions stay fresh without any manual intervention.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/authentication-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/authentication-system
