Skip to main content
RapidDev - Software Development Agency

How to Build a Authentication System with Lovable

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.

What you'll build

  • Email and password sign-up and sign-in with Supabase Auth
  • Magic link (passwordless) sign-in sent to the user's email
  • Google OAuth sign-in button with proper redirect handling
  • A profiles table auto-populated on signup via a PostgreSQL trigger
  • A Tabs-based auth form that switches between Sign In, Sign Up, and Magic Link modes
  • Protected route wrapper that redirects unauthenticated users to the login page
  • A user profile settings page where users update their name and avatar
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate15 min read1.5–2 hoursLovable free tier and aboveApril 2026RapidDev Engineering Team
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.

What you're building

Supabase Auth manages all user credentials and sessions. When a user signs up, Supabase creates a row in the auth.users table. Because auth.users is not directly readable by your app code, you create a public profiles table that mirrors the user's public-facing data (name, avatar, bio). A PostgreSQL trigger function fires on INSERT into auth.users and automatically creates the corresponding profiles row — the frontend never has to do this manually.

Magic links work by sending a one-time login link to the user's email. When clicked, Supabase validates the token, creates a session, and redirects to your app. The redirect URL must be whitelisted in your Supabase Auth settings and updated again when you deploy to a custom domain.

OAuth with Google requires two things: a Google OAuth app configured in Google Cloud Console, and the client ID and secret entered in Supabase Auth settings (Cloud tab → Users & Auth → OAuth providers). OAuth never works in the Lovable preview iframe because the iframe origin doesn't match the redirect URL. Always test OAuth on your published Lovable URL or deployed Vercel URL.

Final result

A production-ready auth system with four sign-in methods, automatic profile creation, and route protection — ready to be extended with any other feature.

Tech stack

LovableFrontend auth UI and routing
Supabase AuthAuthentication provider
SupabaseProfiles table and trigger
shadcn/uiForm, Tabs, Input, Button, Alert, Avatar
React Hook Form + ZodForm validation
React RouterRoute protection and redirects

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create a profiles table in Supabase and a trigger that auto-creates a profile row when a user signs up.
2
3Profiles table:
4 id uuid primary key references auth.users(id) on delete cascade
5 email text not null
6 full_name text
7 avatar_url text
8 bio text
9 updated_at timestamptz default now()
10 created_at timestamptz default now()
11
12RLS:
13 Enable RLS on profiles.
14 SELECT: authenticated users can read all profiles.
15 UPDATE: users can only update their own profile (auth.uid() = id).
16 INSERT: disallow direct INSERT the trigger handles creation only.
17 DELETE: disallow profile is deleted via CASCADE when auth.users row is deleted.
18
19Trigger function (runs AFTER INSERT on auth.users):
20 CREATE OR REPLACE FUNCTION public.handle_new_user()
21 RETURNS trigger
22 LANGUAGE plpgsql
23 SECURITY DEFINER SET search_path = public
24 AS $$
25 BEGIN
26 INSERT INTO public.profiles (id, email, full_name, avatar_url)
27 VALUES (
28 NEW.id,
29 NEW.email,
30 COALESCE(NEW.raw_user_meta_data->>'full_name', split_part(NEW.email, '@', 1)),
31 NEW.raw_user_meta_data->>'avatar_url'
32 );
33 RETURN NEW;
34 END;
35 $$;
36
37 CREATE TRIGGER on_auth_user_created
38 AFTER INSERT ON auth.users
39 FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
40
41Generate 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.

prompt.txt
1Build an authentication page at src/pages/Auth.tsx.
2
3Layout: centered Card with a logo at the top. Three Tabs: 'Sign In', 'Sign Up', 'Magic Link'.
4
5Sign In tab:
6 - Email Input (type='email', required)
7 - Password Input (type='password', required, min 8 chars)
8 - 'Sign In' Button (full width, loading state while submitting)
9 - Call supabase.auth.signInWithPassword({ email, password })
10 - On success: navigate('/dashboard')
11 - On error: show Alert with error.message
12
13Sign Up tab:
14 - Full Name Input (required, min 2 chars)
15 - Email Input (required, valid email)
16 - Password Input (required, min 8 chars, zod refine: at least one number)
17 - Confirm Password Input (must match password)
18 - 'Create Account' Button (loading state)
19 - Call supabase.auth.signUp({ email, password, options: { data: { full_name: name } } })
20 - On success: show Alert 'Check your email to confirm your account'
21 - On error: show Alert with error.message
22
23Magic Link tab:
24 - Email Input (required)
25 - 'Send Magic Link' Button (loading state)
26 - Call supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: window.location.origin + '/dashboard' } })
27 - On success: show success Alert 'Magic link sent — check your inbox'
28
29Below 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' } })
30
31Note: 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
1// src/contexts/AuthContext.tsx
2import { createContext, useContext, useEffect, useState } from 'react'
3import { Session, User } from '@supabase/supabase-js'
4import { supabase } from '@/integrations/supabase/client'
5
6type Profile = {
7 id: string
8 email: string
9 full_name: string | null
10 avatar_url: string | null
11 bio: string | null
12}
13
14type AuthContextType = {
15 user: User | null
16 session: Session | null
17 profile: Profile | null
18 loading: boolean
19 signOut: () => Promise<void>
20}
21
22const AuthContext = createContext<AuthContextType | undefined>(undefined)
23
24export function AuthProvider({ children }: { children: React.ReactNode }) {
25 const [user, setUser] = useState<User | null>(null)
26 const [session, setSession] = useState<Session | null>(null)
27 const [profile, setProfile] = useState<Profile | null>(null)
28 const [loading, setLoading] = useState(true)
29
30 useEffect(() => {
31 supabase.auth.getSession().then(({ data: { session } }) => {
32 setSession(session)
33 setUser(session?.user ?? null)
34 if (session?.user) fetchProfile(session.user.id)
35 else setLoading(false)
36 })
37
38 const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
39 setSession(session)
40 setUser(session?.user ?? null)
41 if (session?.user) fetchProfile(session.user.id)
42 else { setProfile(null); setLoading(false) }
43 })
44
45 return () => subscription.unsubscribe()
46 }, [])
47
48 async function fetchProfile(userId: string) {
49 const { data } = await supabase
50 .from('profiles')
51 .select('id, email, full_name, avatar_url, bio')
52 .eq('id', userId)
53 .single()
54 setProfile(data)
55 setLoading(false)
56 }
57
58 const signOut = async () => {
59 await supabase.auth.signOut()
60 }
61
62 return (
63 <AuthContext.Provider value={{ user, session, profile, loading, signOut }}>
64 {children}
65 </AuthContext.Provider>
66 )
67}
68
69export function useAuth() {
70 const ctx = useContext(AuthContext)
71 if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
72 return ctx
73}

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.

prompt.txt
1Build a ProtectedRoute component and update the app router.
2
3Create src/components/ProtectedRoute.tsx:
4- Import useAuth from AuthContext
5- If loading is true, return a centered Spinner (shadcn/ui skeleton or Loader2 icon from lucide-react)
6- If user is null, return <Navigate to='/auth' replace />
7- Otherwise return <>{children}</>
8
9Update src/App.tsx to use ProtectedRoute:
10- Wrap /dashboard, /profile, /settings, and any other private routes with <ProtectedRoute>
11- /auth and / (landing page) remain unprotected
12- Add the AuthProvider wrapping the entire BrowserRouter
13
14Example router structure:
15<AuthProvider>
16 <BrowserRouter>
17 <Routes>
18 <Route path='/' element={<Landing />} />
19 <Route path='/auth' element={<Auth />} />
20 <Route path='/dashboard' element={
21 <ProtectedRoute>
22 <Dashboard />
23 </ProtectedRoute>
24 } />
25 <Route path='/profile' element={
26 <ProtectedRoute>
27 <Profile />
28 </ProtectedRoute>
29 } />
30 </Routes>
31 </BrowserRouter>
32</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.

prompt.txt
1Build a profile settings page at src/pages/ProfileSettings.tsx.
2
3Requirements:
4- Pre-populate the form with the current user's profile data from useAuth().profile
5- Form fields (react-hook-form + zod):
6 - Full Name (required, min 2 chars, max 100)
7 - Bio (optional, max 300 chars, Textarea with character count)
8 - Avatar: show current avatar_url in an Avatar component, with a 'Change Photo' Button that opens a file input
9- Avatar upload logic:
10 1. User selects an image file (accept='image/*', max 2MB validation)
11 2. Upload to Supabase Storage bucket 'avatars' at path: userId/avatar.{ext}
12 3. Get the public URL: supabase.storage.from('avatars').getPublicUrl(path)
13 4. Store the URL in the avatar_url field
14- Save button: call supabase.from('profiles').update({ full_name, bio, avatar_url, updated_at: new Date().toISOString() }).eq('id', user.id)
15- Show a success toast: 'Profile updated'
16- Show loading state on the save button during submission
17- 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

src/contexts/AuthContext.tsx
1import { createContext, useContext, useEffect, useState } from 'react'
2import { Session, User } from '@supabase/supabase-js'
3import { supabase } from '@/integrations/supabase/client'
4
5type Profile = {
6 id: string
7 email: string
8 full_name: string | null
9 avatar_url: string | null
10 bio: string | null
11}
12
13type AuthContextType = {
14 user: User | null
15 session: Session | null
16 profile: Profile | null
17 loading: boolean
18 signOut: () => Promise<void>
19 refreshProfile: () => Promise<void>
20}
21
22const AuthContext = createContext<AuthContextType | undefined>(undefined)
23
24export function AuthProvider({ children }: { children: React.ReactNode }) {
25 const [user, setUser] = useState<User | null>(null)
26 const [session, setSession] = useState<Session | null>(null)
27 const [profile, setProfile] = useState<Profile | null>(null)
28 const [loading, setLoading] = useState(true)
29
30 const fetchProfile = async (userId: string) => {
31 const { data } = await supabase
32 .from('profiles')
33 .select('id, email, full_name, avatar_url, bio')
34 .eq('id', userId)
35 .single()
36 setProfile(data)
37 setLoading(false)
38 }
39
40 useEffect(() => {
41 supabase.auth.getSession().then(({ data: { session } }) => {
42 setSession(session)
43 setUser(session?.user ?? null)
44 if (session?.user) fetchProfile(session.user.id)
45 else setLoading(false)
46 })
47
48 const { data: { subscription } } = supabase.auth.onAuthStateChange(
49 (_event, session) => {
50 setSession(session)
51 setUser(session?.user ?? null)
52 if (session?.user) fetchProfile(session.user.id)
53 else { setProfile(null); setLoading(false) }
54 }
55 )
56
57 return () => subscription.unsubscribe()
58 }, [])
59
60 const signOut = async () => {
61 await supabase.auth.signOut()
62 }
63
64 const refreshProfile = async () => {
65 if (user) await fetchProfile(user.id)
66 }
67
68 return (
69 <AuthContext.Provider value={{ user, session, profile, loading, signOut, refreshProfile }}>
70 {children}
71 </AuthContext.Provider>
72 )
73}
74
75export function useAuth() {
76 const ctx = useContext(AuthContext)
77 if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
78 return ctx
79}

Customization ideas

Email verification enforcement

Check user.email_confirmed_at in ProtectedRoute. If null, redirect to a /verify-email page that shows instructions and a resend button calling supabase.auth.resend({ type: 'signup', email }).

Multi-factor authentication

Enable TOTP MFA in Supabase Auth settings. Add an MFA setup page in profile settings using supabase.auth.mfa.enroll(). After enrollment, require MFA on every sign-in using supabase.auth.mfa.challenge().

Social login with GitHub

Add a GitHub sign-in button alongside Google. Configure GitHub OAuth in Supabase Auth settings and call supabase.auth.signInWithOAuth({ provider: 'github' }). The trigger auto-creates the profile from GitHub's user metadata.

Remember me functionality

Add a Remember Me Checkbox on the sign-in form. When unchecked, configure the Supabase client with persistSession: false so the session ends when the browser tab closes.

Account deletion flow

Add a danger zone section in profile settings with a Delete Account Button. Require the user to type their email to confirm. Call a service-role Edge Function that calls supabase.auth.admin.deleteUser(userId) — the CASCADE on profiles handles cleanup.

Last-seen tracking

Update profiles.last_seen_at on every successful session fetch. Show this in the user management table as a relative timestamp. This helps identify inactive users for churn analysis.

Common pitfalls

Pitfall: Testing Google OAuth in the Lovable preview iframe

How to avoid: 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.

Pitfall: Reading from auth.users directly in frontend code

How to avoid: 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.

Pitfall: Not adding emailRedirectTo in signInWithOtp

How to avoid: Always pass options: { emailRedirectTo: window.location.origin + '/dashboard' } in signInWithOtp. Also add the URL to Supabase Auth → URL Configuration → Redirect URLs.

Pitfall: Forgetting to handle the SIGNED_IN event after magic link click

How to avoid: 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.

Pitfall: Showing the sign-in form briefly before redirecting already-authenticated users

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I built an auth system in Lovable with Supabase Auth. When users sign up with Google OAuth, the trigger inserts a row into my profiles table. But sometimes the trigger fires before the OAuth user metadata (like full_name) is populated, so profiles get created with null values. How can I handle this? Should I use a different trigger event, add a retry mechanism, or handle it in the frontend?

Lovable Prompt

Add email verification enforcement to my authentication system. After sign-up, if user.email_confirmed_at is null, redirect to /verify-email instead of /dashboard. The verify-email page should show the user's email address, explain they need to confirm it, and have a 'Resend verification email' Button that calls supabase.auth.resend({ type: 'signup', email }). Add a cooldown timer (60 seconds) on the resend button to prevent spam.

Build Prompt

In my Lovable app with Supabase Auth, I'm using onAuthStateChange to listen for sign-in events. After a user clicks a magic link, they're redirected back to my app, but the onAuthStateChange event fires with event='SIGNED_IN' and a valid session. However, I'm using React Router's BrowserRouter and the redirect contains a hash fragment (#access_token=...). How do I ensure the Supabase client processes this hash fragment on page load? Does Lovable's setup handle this automatically?

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.