Auth in the Client
The idea in one sentence
Section titled “The idea in one sentence”supabase.auth gives you signUp, signInWithPassword, and signOut to drive a session, onAuthStateChange to keep your app’s UI in sync with that session automatically, and a hard rule to remember throughout: any check you do against that session in client code is a UX convenience, while row level security on the database is the actual security boundary.
A complete sign-up, sign-in, sign-out flow
Section titled “A complete sign-up, sign-in, sign-out flow”Every one of these calls resolves to { data, error } rather than throwing, so check error before trusting the result — the same pattern you will see formalized in the next lesson.
// Sign up a new userconst { data: signUpData, error: signUpError } = await supabase.auth.signUp({ password: 'correct-horse-battery-staple',});
if (signUpError) { console.error('sign up failed:', signUpError.message);}
// Sign in an existing userconst { data: signInData, error: signInError } = await supabase.auth.signInWithPassword({ password: 'correct-horse-battery-staple',});
if (signInError) { console.error('sign in failed:', signInError.message);}
// Sign out the current userconst { error: signOutError } = await supabase.auth.signOut();
if (signOutError) { console.error('sign out failed:', signOutError.message);}A successful signUp or signInWithPassword call stores a session (an access token and a refresh token) in the client, and every subsequent request from that client is sent as that authenticated user.
Keeping UI state in sync with onAuthStateChange
Section titled “Keeping UI state in sync with onAuthStateChange”Rather than manually tracking whether a user is signed in — and re-checking it after every sign-in, sign-out, or silent token refresh — subscribe once, typically at app startup:
supabase.auth.onAuthStateChange((event, session) => { // Fires on SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, and more setSession(session);});This one subscription fires for every auth-related event for the lifetime of the app: the initial session on load, a sign-in, a sign-out, and a token refresh happening quietly in the background. Wiring your app’s local state to this single callback means the UI is always a reflection of the client’s real session, and you never have to remember to call it manually after each auth action.
Protecting a route: a UX convenience, not real security
Section titled “Protecting a route: a UX convenience, not real security”A common client-side pattern is checking for a session before rendering a protected page or firing an authenticated request:
const { data: { session },} = await supabase.auth.getSession();
if (!session) { // No session: hide the page and send the user to sign in redirectToLogin();}This is genuinely useful — it avoids flashing protected content at a signed-out visitor and avoids firing requests you know will fail. But it must not be mistaken for the actual security mechanism. Client-side JavaScript can always be inspected, modified, or skipped entirely by a determined visitor — nothing stops someone from calling your Supabase project directly with their own token, bypassing your UI altogether. The real boundary that decides whether a request can read or write a given row is row level security (RLS) enforced by Postgres itself, on the server side, regardless of what any client chose to check or render.
flowchart LR
action["signUp() or signInWithPassword()"] --> session["Session created and stored in the client"]
session --> event["onAuthStateChange fires"]
event --> ui["App UI updates; a protected route becomes reachable"]
ui --> rls[("Real enforcement still happens via RLS on the database")]