Skip to content

Auth in the Client

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 user
const { 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 user
const { 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 user
const { 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")]
A session drives the UI, but RLS is still the real gate
What does subscribing once to onAuthStateChange let you avoid doing manually?
Why is checking session before rendering a protected page only a UX convenience?
What actually enforces which rows a request can read or write, regardless of client-side checks?
What shape does a call like supabase.auth.signOut() resolve to?