Supabase has some of the best developer experience (DX) in the BaaS space right now. DX is shorthand for how pleasant a tool is to work with — the docs, the dashboard, the auto-generated APIs, the speed at which you go from zero to something working. On all of those: Supabase is excellent.
But excellent DX has a shadow side. When a platform abstracts things this smoothly, it's easy to forget what's running underneath. And what's running underneath Supabase is PostgreSQL — a powerful, strict, battle-tested database that has its own permission system, its own rules, and absolutely no obligation to care how clean your dashboard looks.
These are the two things that will catch you — usually at the same time, usually showing the same symptom.
The Symptom
You've set up auth. Users sign up, verify their email, try to log in — and your app shows something like a "pending approval" or "no role assigned" screen even though everything completed correctly.
Your trigger is firing. Your RLS policies are in place. The Supabase dashboard looks fine.
Nothing is broken. Nothing is obviously wrong.
This is the trap.
Gotcha 1: The GRANT That Nobody Told You About
This is the most common question in the Supabase Discord and it's easy to see why — it violates every reasonable expectation.
When you enable Row Level Security (RLS) on a table in Supabase, you create policies that define who can do what with the rows. A policy might say: "authenticated users can read their own rows." Logical. Clear.
What the dashboard doesn't shout at you is that RLS policies and table permissions are two entirely separate systems in PostgreSQL.
Here's the mental model:
| Layer | What it controls | Analogy |
|---|---|---|
| GRANT | Can this role access the table at all? | The keycard that opens the building |
| RLS Policy | Which rows can this role see? | Which floors that keycard can reach |
Without a GRANT, the keycard doesn't work. PostgreSQL's PostgREST layer turns you away at the door before RLS even runs. You can have perfectly written policies and they will never be evaluated — because the role was never let in to begin with.
This is why you can get permission denied for table user_roles even with the service_role key — the key that's supposed to bypass everything. Without explicit GRANTs, "supposed to bypass everything" turns out to have an asterisk.
The fix is two lines that most Supabase tutorials never show you:
GRANT ALL ON public.user_roles TO service_role, authenticated;
GRANT ALL ON public.profiles TO service_role, authenticated;
That's it. Two lines of SQL that most migration guides don't include.
The Migration Template You Should Steal
Every time you create a table in Supabase, this is the full pattern. Save it as a GitHub Gist. Tattoo it somewhere:
-- 1. Create the table
CREATE TABLE public.my_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. Enable RLS
ALTER TABLE public.my_table ENABLE ROW LEVEL SECURITY;
-- 3. Write your policies
CREATE POLICY "authenticated_can_select"
ON public.my_table
FOR SELECT TO authenticated
USING (true);
-- 4. THE PART EVERYONE FORGETS
GRANT ALL ON public.my_table TO service_role, authenticated;
GRANT SELECT ON public.my_table TO anon;
Step 4 is not optional. It's not an edge case. It is required every single time, and the dashboard will not remind you.
The 60-Second Diagnostic
Hit permission denied and not sure why? Run these three queries in the Supabase SQL editor:
-- Is RLS actually enabled?
SELECT relname, relrowsecurity
FROM pg_class WHERE relname = 'your_table_name';
-- Do your policies exist?
SELECT policyname, cmd, roles
FROM pg_policies WHERE tablename = 'your_table_name';
-- Do GRANTs exist? (this is usually the missing one)
SELECT grantee, privilege_type
FROM information_schema.table_privileges
WHERE table_name = 'your_table_name';
If the third query returns nothing for service_role or authenticated — you found it.
Gotcha 2: The Race Condition Nobody Mentions
This one is subtler and it shows up as a UI flicker — your app briefly flashes an "account pending" or "access denied" screen for a user who is fully verified and approved.
It's not a database problem. It's a timing problem.
When a user logs in, your frontend is doing at least two async things at once: confirming the auth session exists, and fetching the user's role from the database. If your UI renders before the role fetch completes, it sees role = null for a split second and acts accordingly — showing whatever your "no role" fallback state is.
The common mistake is initialising the loading state as false:
// This is the trap
const [roleLoading, setRoleLoading] = useState(false);
false means "done loading." Your UI reads that and renders immediately, before the role has arrived.
The fix is using null as the initial state instead:
// null = "I genuinely don't know yet — don't render anything"
const [roleLoading, setRoleLoading] = useState(null);
null means "ask me later." Your UI holds. The role arrives. The correct screen renders. No flicker.
It's a one-character change — false to null — that's the difference between a UI that lies to users for 200ms and one that waits until it knows the truth.
Why This Happens With BaaS Platforms Specifically
Both of these issues share a root cause: Supabase abstracts so much so well that it's easy to stop thinking about the layer underneath.
The dashboard gives you toggles. The client library gives you clean methods. The auto-generated APIs give you something that feels like it's handling everything. And mostly it is — until it isn't, and the failure is silent, and the symptom looks identical whether the problem is a missing GRANT or a race condition or something else entirely.
This isn't a criticism. The abstraction is genuinely the point, and it's genuinely good. But every abstraction has a floor. With Supabase, the floor is PostgreSQL — and PostgreSQL has opinions.
The moment you remember that Supabase is PostgreSQL with excellent packaging, a lot of otherwise mysterious behaviour starts making sense.
The Pre-Launch Auth Checklist
Before you ship any Supabase auth flow:
- [ ] Every table has both RLS policies and GRANT statements
- [ ] Role/loading state initialises as
null, notfalse - [ ] Tested clean: run
npx supabase db reset --localfrom scratch at least once - [ ] Tested with both
anonkey andservice_rolekey separately
That third one matters more than it looks. If you've only ever run your app against a database that was built incrementally and never torn down, you haven't found all your migration gaps yet. A clean reset is the smoke detector.
Hit a Supabase gotcha that this doesn't cover? Drop it in the comments — these things rarely travel alone. 👇
Backend engineer. I write about the bugs I actually hit. Portfolio: cycy.is-a.dev 🚀
Top comments (0)