Supabase RLS without GRANT: why is the screen empty with no error?

Without a GRANT on the table, the RLS policy never runs. How Supabase and Postgres handle it, why the UI comes up empty, and how to check it with one query.

Pedro Henrique Quadro4 min read

Because RLS (Row Level Security, the rule that decides which rows each user can see) only kicks in after the database checks that the user has permission on the table, and that permission is granted with the GRANT command. If the GRANT does not exist, the policy is never evaluated. Depending on the path the read takes, the result shows up as an error or as an empty list, and the empty list is what cost me time. The fix is to write the GRANT in the migration, next to the policy, instead of relying on a project default. I checked the sources on October 10, 2026.

What the documentation says

The Supabase documentation describes two checks in sequence: first the grants, then the policies that filter rows. A request without the required grant fails with code 42501 (permission denied) before any policy is evaluated, and the typical message is permission denied for table. The same text also warns that adding policies does not remove grants that already exist.

The PostgreSQL documentation says the same thing another way: row security policies exist in addition to the SQL privilege system (GRANT). With RLS enabled and no policy, default-deny applies, meaning no row is visible.

So there are two layers with different roles. The GRANT is the gate to the whole table. The policy is the ruler that picks rows inside it. One does not replace the other.

What I saw in practice

On a project I worked on, the database was recreated from the migrations in a new Supabase project. The tables, the data and all the policies came up. The app opened with every screen empty and no error message in the UI. No migration wrote a GRANT.

Two details made the problem hard to see:

  • The collector that writes data (an ingestion job) used the service_role key. That role has the bypassrls attribute, which skips policies, but skipping policies is not the same as having table privilege. The Supabase documentation says service_role ignores RLS and does not say it makes the GRANT unnecessary. In my case, writes stopped too.
  • One screen kept working. It read through a SECURITY DEFINER function, which runs with the permissions of whoever created it and does not depend on the caller's privilege. That page made the database look healthy, while the 16 tables were inaccessible to the logged-in user.

Why it shows up when you recreate the project

In projects created under the old default, Supabase automatically grants privileges on every new table in the public schema. In the project I looked at, this came from ALTER DEFAULT PRIVILEGES. According to the PostgreSQL documentation, that command sets the privileges of objects created in the future and does not touch existing ones. The Supabase API security documentation says the platform is changing the default to revoke these automatic grants, so that exposing a table becomes an explicit choice. I measured the difference in my project: under the new default, the default-privileges configuration did not include SELECT, INSERT, UPDATE or DELETE for the API roles.

The result is that a schema that only worked because the source project handed out the GRANT for free stops working when it is recreated elsewhere. That schema was not truly versioned: part of what it needed lived outside the migrations.

On the symptom of an empty screen instead of an error: the Supabase documentation states that 42501 is returned, and I saw empty screens. I did not find an explanation in the documentation for why my app's UI swallowed the error, and that depends on how each screen handles the response. What I can state is what I observed.

How to check it with one query

This query lists the tables in the public schema and the DML privileges (read and write) of the authenticated role. A table with an empty column has no access:

SELECT t.tablename, string_agg(g.privilege_type, ',') AS dml
FROM pg_tables t
LEFT JOIN information_schema.role_table_grants g
  ON g.table_name = t.tablename
 AND g.table_schema = 'public'
 AND g.grantee = 'authenticated'
WHERE t.schemaname = 'public'
  AND (g.privilege_type IS NULL
       OR g.privilege_type IN ('SELECT','INSERT','UPDATE','DELETE'))
GROUP BY 1
ORDER BY 2 NULLS FIRST;

Run this query as an admin user: information_schema.role_table_grants only shows the grants the querying role can see, and it does not follow role inheritance.

What to do

  1. Write the GRANT in the migration itself, next to the policy, mirroring what the policy intends. Example for a deals table read and edited by logged-in users (the GRANT is the gate, the policy fine-tunes):
ALTER TABLE public.deals ENABLE ROW LEVEL SECURITY;

GRANT SELECT, INSERT, UPDATE, DELETE ON public.deals TO authenticated;

-- minimal example policy: in production, replace true with your tenant filter
CREATE POLICY deals_leitura ON public.deals
  FOR SELECT TO authenticated
  USING (true);
  1. Granting default privileges to the ingestion identity (service_role) makes sense. For authenticated I prefer not to: that way, every new table has to declare that the UI may read it.
  2. Run the query above after every environment recreation, before opening the app.
  3. Test the app with a regular logged-in user, not only with the service key. The service key hides exactly this defect.

If you need a system with a database, permissions and multi-tenancy locked down from the start, see how I work on systems and SaaS.

Sources

  1. Supabase: grants come before policies, error 42501 and service_role with bypassrls
  2. Supabase: a table is only reachable through the Data API with a GRANT, and the change in automatic grant defaults
  3. PostgreSQL: row security policies exist in addition to the GRANT system, and default-deny without a policy
  4. PostgreSQL: ALTER DEFAULT PRIVILEGES applies only to objects created afterwards
Pedro Henrique QuadroSoftware and AI engineer. Builds apps, systems, dashboards and AI agents in production, with code merged into Supabase, Kestra and QuestDB.

Keep reading