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
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
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.
1Create a profiles table in Supabase and a trigger that auto-creates a profile row when a user signs up.23Profiles table:4 id uuid primary key references auth.users(id) on delete cascade5 email text not null6 full_name text7 avatar_url text8 bio text9 updated_at timestamptz default now()10 created_at timestamptz default now()1112RLS: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.1819Trigger function (runs AFTER INSERT on auth.users):20 CREATE OR REPLACE FUNCTION public.handle_new_user()21 RETURNS trigger22 LANGUAGE plpgsql23 SECURITY DEFINER SET search_path = public24 AS $$25 BEGIN26 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 $$;3637 CREATE TRIGGER on_auth_user_created38 AFTER INSERT ON auth.users39 FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();4041Generate 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.
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.
1Build an authentication page at src/pages/Auth.tsx.23Layout: centered Card with a logo at the top. Three Tabs: 'Sign In', 'Sign Up', 'Magic Link'.45Sign 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.message1213Sign 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.message2223Magic 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'2829Below 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' } })3031Note: 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.
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.
1// src/contexts/AuthContext.tsx2import { createContext, useContext, useEffect, useState } from 'react'3import { Session, User } from '@supabase/supabase-js'4import { supabase } from '@/integrations/supabase/client'56type Profile = {7 id: string8 email: string9 full_name: string | null10 avatar_url: string | null11 bio: string | null12}1314type AuthContextType = {15 user: User | null16 session: Session | null17 profile: Profile | null18 loading: boolean19 signOut: () => Promise<void>20}2122const AuthContext = createContext<AuthContextType | undefined>(undefined)2324export 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)2930 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 })3738 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 })4445 return () => subscription.unsubscribe()46 }, [])4748 async function fetchProfile(userId: string) {49 const { data } = await supabase50 .from('profiles')51 .select('id, email, full_name, avatar_url, bio')52 .eq('id', userId)53 .single()54 setProfile(data)55 setLoading(false)56 }5758 const signOut = async () => {59 await supabase.auth.signOut()60 }6162 return (63 <AuthContext.Provider value={{ user, session, profile, loading, signOut }}>64 {children}65 </AuthContext.Provider>66 )67}6869export function useAuth() {70 const ctx = useContext(AuthContext)71 if (!ctx) throw new Error('useAuth must be used inside AuthProvider')72 return ctx73}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.
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.
1Build a ProtectedRoute component and update the app router.23Create src/components/ProtectedRoute.tsx:4- Import useAuth from AuthContext5- 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}</>89Update src/App.tsx to use ProtectedRoute:10- Wrap /dashboard, /profile, /settings, and any other private routes with <ProtectedRoute>11- /auth and / (landing page) remain unprotected12- Add the AuthProvider wrapping the entire BrowserRouter1314Example 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.
Build the profile settings page
Create the profile settings page where users update their name, bio, and avatar. Avatar upload uses Supabase Storage.
1Build a profile settings page at src/pages/ProfileSettings.tsx.23Requirements:4- Pre-populate the form with the current user's profile data from useAuth().profile5- 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 input9- 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 field14- 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 submission17- Create the 'avatars' Supabase Storage bucket with public access and a policy: authenticated users can upload to their own userId/ pathPro 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
1import { createContext, useContext, useEffect, useState } from 'react'2import { Session, User } from '@supabase/supabase-js'3import { supabase } from '@/integrations/supabase/client'45type Profile = {6 id: string7 email: string8 full_name: string | null9 avatar_url: string | null10 bio: string | null11}1213type AuthContextType = {14 user: User | null15 session: Session | null16 profile: Profile | null17 loading: boolean18 signOut: () => Promise<void>19 refreshProfile: () => Promise<void>20}2122const AuthContext = createContext<AuthContextType | undefined>(undefined)2324export 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)2930 const fetchProfile = async (userId: string) => {31 const { data } = await supabase32 .from('profiles')33 .select('id, email, full_name, avatar_url, bio')34 .eq('id', userId)35 .single()36 setProfile(data)37 setLoading(false)38 }3940 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 })4748 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 )5657 return () => subscription.unsubscribe()58 }, [])5960 const signOut = async () => {61 await supabase.auth.signOut()62 }6364 const refreshProfile = async () => {65 if (user) await fetchProfile(user.id)66 }6768 return (69 <AuthContext.Provider value={{ user, session, profile, loading, signOut, refreshProfile }}>70 {children}71 </AuthContext.Provider>72 )73}7475export function useAuth() {76 const ctx = useContext(AuthContext)77 if (!ctx) throw new Error('useAuth must be used inside AuthProvider')78 return ctx79}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.
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?
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation