Back to blog
Tutorial

Supabase Auth: secure authentication from zero

By Flávio Emanuel · · 9 min read

Authentication is one of the most important parts of your app. It’s also one of the most fragile if you get it wrong.

I used Firebase for years. Switched to Supabase. Why? RLS (Row Level Security). You define access rules directly in the database. It’s secure by default.

With Firebase, any client could read any data. You had to add security rules in the Realtime Database. Easy to forget. With Supabase, the database is the gatekeeper.

Basic setup: creating a user

Supabase Auth uses JWT. When you log in, you get a token. Send that token on every request and Supabase validates it.

First thing: create a user profile table. Not required, but good to have extra info beyond email.

create table public.profiles (
  id uuid references auth.users on delete cascade,
  name text,
  avatar_url text,
  created_at timestamp default now(),
  primary key (id)
);

Then add a trigger that creates the profile automatically when the user signs up:

create function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, name)
  values (new.id, new.email);
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

Simple. User registers, profile is created automatically.

Login: email and password

I use React for my projects. Here’s the pattern that works:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
)

async function handleLogin(email, password) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })

  if (error) {
    console.error('Login failed:', error.message)
    return
  }

  // data.session contains the token
  // data.user contains user information
  localStorage.setItem('session', JSON.stringify(data.session))
}

You can store the session in localStorage or in memory. Supabase automatically refreshes the refresh token.

For sign up:

async function handleSignUp(email, password, name) {
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      data: { name }
    }
  })

  if (error) throw error
}

The returned data has the created user. Supabase sends a confirmation email. You can disable this in the admin panel if you want.

OAuth: Google login

Supabase supports Google, GitHub, Discord, and more. Configure it in the panel and then it’s one line:

async function signInWithGoogle() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo: `${import.meta.env.VITE_APP_URL}/auth/callback`
    }
  })
}

This opens the Google popup. User logs in. Comes back to your callback URL with the token. Supabase handles the rest.

The callback URL needs to do this:

// pages/auth/callback.jsx (Astro)
import { supabase } from '@lib/supabase'

export async function getServerSideProps({ url }) {
  const code = url.searchParams.get('code')

  if (code) {
    await supabase.auth.exchangeCodeForSession(code)
  }

  return {
    redirect: {
      destination: '/',
      permanent: false,
    },
  }
}

Done. User is logged in.

Using sessions in your app

You want to know who’s logged in. This works anywhere:

async function getUser() {
  const { data, error } = await supabase.auth.getUser()

  if (error) {
    console.log('User not logged in')
    return null
  }

  return data.user
}

If you want to restore the session on app load:

const { data } = await supabase.auth.getSession()

if (data.session) {
  console.log('User is logged in')
} else {
  console.log('User is logged out')
}

Supabase automatically refreshes the refresh token. You don’t do anything.

Row Level Security (RLS): where security lives

RLS is where real security lives. You write SQL policies that say: “this user can only see their own data”.

First, enable RLS on the table:

alter table public.profiles enable row level security;

Then create a policy. Example: user only sees their own profile:

create policy "Users can view own profile"
  on public.profiles
  for select
  using (auth.uid() = id);

Now nobody can do select * from profiles. Supabase automatically filters to show only the logged-in user’s profile.

Another example: dental clinic. Patient only sees their own appointment:

create table public.appointments (
  id uuid primary key default gen_random_uuid(),
  patient_id uuid references auth.users not null,
  clinic_id uuid not null,
  scheduled_at timestamp not null
);

alter table public.appointments enable row level security;

create policy "Patients see only their appointments"
  on public.appointments
  for select
  using (auth.uid() = patient_id);

create policy "Patients can book appointments"
  on public.appointments
  for insert
  with check (auth.uid() = patient_id);

Now it’s impossible for a user to see another user’s appointments. It’s in the database. Your code can’t bypass it.

Testing authentication

To test, you need to actually log in. Supabase provides a client for Node:

const { createClient } = require('@supabase/supabase-js')

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY // Use service role for tests
)

// Creates a user
const { data, error } = await supabase.auth.admin.createUser({
  email: 'test@example.com',
  password: 'password123',
  email_confirm: true
})

// Then test with that account

Service role key is like super admin. Never expose this in the frontend.

Security checklist

  • Use HTTPS in production (Vercel handles this)
  • Enable RLS on ALL tables
  • Never use service role key in frontend
  • Test that anonymous user can’t access data
  • Configure password reset email
  • Enable email verification for production
  • Run authentication tests before deploy

Supabase Auth with RLS is a powerful combination. You have security built into the database, no extra code needed.

Refresh tokens: keep it automatic

Common mistake I see: dev tries to handle refresh tokens manually.

Supabase does it automatically. But you need to configure it right.

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY,
  {
    auth: {
      persistSession: true,
      detectSessionInUrl: true,
    }
  }
)

With persistSession: true, Supabase automatically:

  1. Saves token to localStorage
  2. When token expires, uses refresh token to generate new one
  3. You touch nothing

If you don’t set this, user is logged in 5 minutes then loses session. Bad experience.

Multiple OAuth providers

Supabase supports Google, GitHub, Discord, Microsoft, LinkedIn, Apple. You can offer all:

async function signInWithProvider(provider) {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: provider, // 'google', 'github', 'discord', etc
    options: {
      redirectTo: `${import.meta.env.VITE_APP_URL}/auth/callback`
    }
  })
}

Used this on a clinic project where patients could log in with Google (regular users) or Facebook (staff). Same app, different providers.

Security: never store token manually

Most common error:

// DON'T DO THIS
localStorage.setItem('token', data.session.access_token)

Then on requests:

// DON'T DO THIS
const token = localStorage.getItem('token')
fetch('/api/data', {
  headers: { Authorization: `Bearer ${token}` }
})

Why is it bad? If token leaks, it’s leaked forever.

Supabase already stores and refreshes. You just use:

const { data, error } = await supabase
  .from('data')
  .select('*')

Supabase automatically puts token in header, refreshes when expired, you never touch it.

Revoking access

Sometimes you need to log users out:

async function logout() {
  const { error } = await supabase.auth.signOut()
  if (error) console.error('Logout failed:', error)
}

Supabase automatically invalidates the token. If user tries to use old session, rejected.

Useful for security breach, or when user changes password. All old tokens become invalid.

Advanced RLS: multi-tenant

Scenario: scheduling app for multiple clinics. Patient of clinic A can’t see clinic B appointments.

-- Clinics table
create table clinics (
  id uuid primary key,
  name text,
  owner_id uuid references auth.users
);

-- Patients table (multi-tenant)
create table patients (
  id uuid primary key,
  clinic_id uuid references clinics not null,
  user_id uuid references auth.users,
  name text
);

-- Enable RLS
alter table patients enable row level security;

-- Policy: user sees patients only from their clinic
create policy "Users see patients from their clinic"
  on patients
  for select
  using (
    clinic_id = (
      select id from clinics where owner_id = auth.uid()
    )
  );

Now when you do select * from patients, Supabase automatically filters to show only patients from logged-in user’s clinic.

Impossible to query outside that scope.

Auditing: track who changed what

Supabase has built-in audit logs. Go to admin panel > Logs.

But for custom auditing, create a table:

create table audit_log (
  id uuid primary key default gen_random_uuid(),
  table_name text not null,
  user_id uuid references auth.users,
  action text not null, -- 'INSERT', 'UPDATE', 'DELETE'
  old_data jsonb,
  new_data jsonb,
  created_at timestamp default now()
);

-- Trigger on patients
create function log_patient_changes()
returns trigger as $$
begin
  insert into audit_log (table_name, user_id, action, old_data, new_data)
  values (
    'patients',
    auth.uid(),
    TG_OP,
    row_to_json(OLD),
    row_to_json(NEW)
  );
  return COALESCE(NEW, OLD);
end;
$$ language plpgsql;

create trigger patients_audit
  after insert or update or delete on patients
  for each row execute function log_patient_changes();

Now every change to patients is logged with who did it, when, what changed.

Useful for compliance, GDPR, internal audit.

Testing everything locally

Supabase provides CLI to run local:

supabase start

This spins up Postgres, Auth, Storage all local. Develop offline, no staging needed.

Then when ready:

supabase push

Pushes migrations to production.

With MVP from zero to deploy, I talked about this. Local development matters.

  • Configure Supabase Auth with persistSession
  • Implement login with 2+ OAuth providers
  • Create basic RLS (user sees only their data)
  • Add multi-tenant RLS (clinic A vs B)
  • Set up audit log for compliance
  • Test token revocation
  • Local development setup with CLI
  • Run migrations in staging before prod

Supabase Auth done right is security your client pays for.

Next step

Need a dev who truly delivers?

Whether it's a one-time project, team reinforcement, or a long-term partnership. Let's talk.

Chat on WhatsApp

I reply within 2 hours during business hours.