Supabase auth for a solo builder
The setup that works, why row level security is the whole point, and the three mistakes that quietly expose your users' data.
Supabase auth is free, it's attached to your database, and it takes about 20 minutes to wire up. The catch is that the auth is the easy half. Row level security is the half that matters, and it's the half people skip.
Start with magic links
Passwords mean reset flows, breach risk and a support inbox. For a side project with no users yet, skip them.
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${location.origin}/auth/callback` }
})
Add GitHub OAuth if your users are developers. One extra button, and it removes the "check your email" step that loses about a fifth of signups.
Swap the email provider on day one
Supabase's built-in mailer is rate limited to a handful an hour and sends from their domain. Your magic links will land in spam.
Point it at Resend in Project Settings, Auth, SMTP. Free tier covers 3,000 a month, which is more than enough, and your links arrive from your own domain.
People discover this after their first real user can't log in. Do it before that.
Row level security is the actual product
Here's the thing that catches everyone: your anon key is in the browser. Anyone can read it, open a console, and query your database directly.
RLS is the only thing stopping them. Without a policy, a table is either fully open or fully closed, and Supabase will happily let you ship the open version.
alter table notes enable row level security;
create policy "own notes only"
on notes for select
using (auth.uid() = user_id);
create policy "insert own notes"
on notes for insert
with check (auth.uid() = user_id);
Note that select uses using and insert uses with check. Getting those the wrong way round is a common way to write a policy that does nothing.
The three mistakes
Enabling RLS and forgetting a policy. The table goes silent. Every query returns an empty array and no error. You'll spend an hour blaming your query.
Writing a select policy and no insert policy. Reads are locked down, writes are refused, and the error is vague enough to send you the wrong way.
Using the service role key in something the browser can reach. The service role bypasses RLS entirely. It belongs in server code only. Put a guard on it:
import 'server-only' // makes a client import a build error
That one line turns a silent data leak into a failed build.
Check it from the outside
Trusting your own reading of a policy is how leaks happen. Test it as an anonymous user:
curl "https://<project>.supabase.co/rest/v1/notes?select=*" \
-H "apikey: <your anon key>"
If that returns anybody's rows, your policy is wrong. Run this against every table before you launch. It takes 5 minutes and it's the only test that actually proves anything.
Profiles need a trigger
Supabase puts users in auth.users, which you shouldn't join to directly. Make a profiles table and fill it automatically:
create function handle_new_user() returns trigger
language plpgsql security definer as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email);
return new;
end $$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function handle_new_user();
Without this, half your users have no profile row and every page that joins to it breaks for them.
When to pay for Clerk instead
Supabase auth is worth it when you're already using Supabase for data, because the RLS integration is the point.
Clerk is worth £20 a month when you need organisations, team invites, or SSO. Building multi-tenant permissions yourself is a genuinely hard problem and about 3 weeks of work you'll get wrong twice.
For one person shipping a side project with individual accounts, Supabase is the right answer and the £20 is better spent on a domain and a database that doesn't pause.