Diagnosing Supabase RLS Policies That Return 403 or Empty Results
Authenticated but getting 403s or empty results from Supabase? Diagnose Row Level Security misconfigurations with pg_policies queries, JWT role checks, and targeted fixes.
11 Sept 2025, 09:15 UTC

The symptom: authenticated, but no data
You sign in through Supabase Auth, query a table with the JavaScript client or the PostgREST API, and get either a 403 Forbidden response or a successful response containing zero rows — even though the rows clearly exist in the dashboard. In almost every case this is Row Level Security (RLS) doing exactly what it was told to do. The problem is that what it was told is not what you meant.
The useful takeaway: a 403 and an empty result set point to different failure modes, and you can distinguish them in minutes by reading the policy definitions in pg_policies and temporarily bypassing RLS as a privileged role.
How RLS fails silently
When RLS is enabled on a table, Postgres evaluates a policy's USING clause for every row a query touches. Rows that fail the check are filtered out without an error — this is why a broken SELECT policy usually looks like an empty table rather than a denial. A 403 from PostgREST more often means the API role (typically anon or authenticated) lacks a table-level GRANT, or no permissive policy exists at all for the operation. Writes fail differently: the WITH CHECK clause validates the row being inserted or updated, and a failed check raises an explicit error.
Symptom-to-cause map
| Symptom | Likely cause | Quick check |
|---|---|---|
| 403 on SELECT | No permissive policy for the role, or missing table GRANT | Query pg_policies; check information_schema.role_table_grants |
| 200 with zero rows | Policy USING clause excludes the rows (or is literally USING (false)) | Read the policy definition; compare against auth.uid() |
| Works in SQL editor, fails from client | SQL editor runs as a privileged role that bypasses RLS; client runs as authenticated | Reproduce with an actual signed-in user JWT |
| INSERT/UPDATE error, SELECT fine | Missing or wrong WITH CHECK clause | Inspect the policy's with_check column in pg_policies |
| Policy looks right, still denied | Wrong role claim in the JWT (e.g., querying as anon) | Inspect the token's role claim |
Ordered diagnostic checks
Run these in the Supabase SQL editor (which connects as a privileged Postgres role) unless noted otherwise.
- Confirm which role the client is actually using. Decode the JWT your client sends (the access token from
supabase.auth.getSession()) and look at theroleclaim. An expired or missing session silently falls back toanon, which is the single most common cause of "it worked yesterday" reports. - List the policies on the table:
TheSELECT policyname, cmd, roles, qual, with_check FROM pg_policies WHERE schemaname = 'public' AND tablename = 'your_table';qualcolumn is theUSINGexpression;with_checkis the write-validation expression. Check that a policy exists for the command you're running (SELECT,INSERT, etc.) and thatrolesincludes your role (or{public}). - Confirm RLS is actually enabled (and not, say, enabled on a different table than the one the client queries):
SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'your_table'; - Test whether the data is reachable without RLS. In the SQL editor, run:
If rows appear, the data exists and the policy logic is the problem. If the count is still zero, the issue is elsewhere — a view definition, a schema mismatch, or the client pointing at a different project.SET row_security = off; SELECT count(*) FROM public.your_table; RESET row_security; - Evaluate the policy expression by hand. Take the
qualexpression and run it as a query with a real user ID substituted:
Note that in the SQL editorSELECT * FROM public.your_table WHERE auth.uid() = user_id; -- the policy conditionauth.uid()returns NULL because no JWT is present, so substitute a literal UUID instead. If the literal-UUID query returns rows but the policy returns none, the JWT'ssubclaim isn't matching youruser_idcolumn — check for type mismatches (e.g., comparing auuidcolumn totext).
Fixes tied to each finding
No policy for the operation. Create one scoped to the command and role. A typical ownership policy:
CREATE POLICY select_own_rows ON public.your_table
FOR SELECT TO authenticated
USING (auth.uid() = user_id);Writes need the check clause too:
CREATE POLICY insert_own_rows ON public.your_table
FOR INSERT TO authenticated
WITH CHECK (auth.uid() = user_id);Accidental USING (false) or leftover placeholder. Replace it rather than stacking a second policy — multiple permissive policies are OR-ed together, which makes reasoning harder:
DROP POLICY old_policy_name ON public.your_table;
CREATE POLICY select_own_rows ON public.your_table
FOR SELECT TO authenticated USING (auth.uid() = user_id);Client running as anon. Fix the session handling in your app — the token isn't being attached. Verify by logging the role claim from the decoded JWT before changing any policy.
Missing table GRANT. RLS sits on top of regular Postgres privileges. The default Supabase setup grants the API roles access to public tables, but tables created through raw SQL by other roles can miss this:
GRANT SELECT ON public.your_table TO authenticated;Verifying the fix
Do not verify from the SQL editor — it bypasses RLS. Instead, create a test user with known rows, sign in through your actual client, and run the failing query. Confirm the returned rows match what you expect for that user's ID. For a second angle, use the REST endpoint directly with the user's access token in the Authorization: Bearer header and compare the response.
Escalation criteria
Take the problem beyond policy edits when:
- Queries still fail after you've confirmed data exists with
row_security = offand the policy expression evaluates correctly with a literal user ID — this suggests role inheritance or a corrupted catalog entry, which needs a database administrator or Supabase support. - The same failure appears across multiple unrelated tables at once, pointing at project-level configuration rather than one policy.
- Queries with RLS enabled time out while the same queries with RLS off are fast — that indicates the policy expression needs indexing or simplification (a common culprit is an unindexed subquery inside
USING), which is a performance review, not a permissions fix. - Audit logs show
permission denied for relationdespite correct-looking policies and grants.
Cautions
Never disable RLS on a production table to "test" — doing so exposes every row to any authenticated key, even briefly. Make policy changes in a local Supabase dev instance or a branch project first, and re-run the low-privilege verification before shipping. Also remember that permissive policies combine with OR logic: adding a broad policy to "fix" access can silently open rows that a stricter policy was protecting.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.