Why an empty list is the expected result, not a fault
Row-level security (RLS) in Postgres is default-deny. PostgreSQL's documentation says that once it is enabled on a table, all normal access must be allowed by a policy, and with no policy no rows are visible or can be modified. Supabase's guide says the same for its data interface: with RLS enabled and no policies, no data is accessible with the browser-side key until you create them. Crucially the failure is silent. A query that no policy allows does not raise an error; it returns an empty list, which looks exactly like a table with no rows.
So "the data is in the dashboard but my app shows nothing" is a classic result of turning protection on, not of losing data.
- Check whether RLS is enabled for the table.
- Check which policies exist and which operations each covers.
- Remember the dashboard's own view does not use your app's identity.
Four checks in order
First, is the user signed in at the moment of the call? Supabase documents that auth.uid() returns null when no one is authenticated, so a policy that compares a column with it never matches. Server-side code that builds its own connection without the user's session is effectively a signed-out visitor. Second, does the policy target the right role? Naming the signed-in role explicitly (to authenticated) stops the expression running for signed-out requests. Third, does a policy cover the operation you are trying? Reading uses a using expression, inserting uses with check, updating uses both and needs a matching read policy as well, and Supabase advises one policy per operation rather than a catch-all. Fourth, does the expression match your data: the owner column, its type and the identifier the signed-in user really has.
- Empty on read: signed-out session, wrong role or no read policy.
- Refused on insert: missing with check or a check that the new row fails.
- Update affects zero rows: no matching read policy, or the using expression excludes the row.
Do not make data appear by removing the protection
Two shortcuts are tempting and dangerous. Turning RLS off lets anyone who holds your browser-side key read and change every row the key can reach. Using the secret (service role) key in browser code does make the data appear, but Supabase documents that this key bypasses RLS and must stay server-side; in the browser it hands every visitor full access. If either has already happened to a table with customer data, treat it as an incident and rotate the key, which only you can do, before any repair. Likewise do not base policies on user_metadata, which Supabase notes users can edit.
- A policy that always returns true is the same as no protection.
- A key found in browser code should be treated as exposed.
- Put authorisation facts in data users cannot edit.
A safe first investigation on a copy
Do this on a non-production project or a local copy with synthetic rows, never live data. Create two test users. As user A, read the table; as user B read the same table; then make a signed-out request. Write down what each sees. Open the table's policies and compare the expression with the owner column. Note that views bypass RLS unless they are created with security_invoker (Postgres 15 and later) and that grants are a separate check that can produce a permission error before any policy runs. Add an index on a column that policies filter on if the table is large, and wrap auth.uid() in a select, as Supabase advises for performance.
- A sees own rows, B sees none of A's: the rule works for reading.
- B can change A's row: the policy is too wide.
- Everything is empty for everyone: the session is probably not reaching the call.
What fixes it and how the paid job is accepted
A repair writes the narrowest policies that fit a rule you can state in a sentence or two, such as each user reaching only their own rows, and proves it with a two-user test. Our fixed-scope job (posted test price from £395, untested, quoted after we read the table and the rule) covers one table, delivers the policies as a migration you run yourself, and is accepted when user A reads exactly their own rows, user B and a signed-out request read none of them and cannot change them, and no server-only key is in browser code. It is not a security audit, and complex team or role sharing is a larger job. Send the table, the owner column and the rule in your first enquiry, not keys or real rows.
Sources and limits
- Supabase: Row Level Security Checked 2026-10-11.
- With RLS enabled and no policies no data is accessible through the API with a publishable key; grants are a separate check; SELECT and DELETE use using, INSERT uses with check, UPDATE uses both and needs a matching SELECT policy; auth.uid() returns null when no one is signed in so the policy silently denies; name the target role with to authenticated; the secret/service_role key bypasses RLS; do not base policies on user_metadata, which users can change; policy performance advice includes wrapping auth.uid() in a select and indexing filtered columns; views bypass RLS unless security_invoker is set on Postgres 15 and later.
- Supabase: API keys Checked 2026-10-11.
- The publishable key is safe in the browser because it reaches only what Row Level Security allows; the secret key bypasses RLS and must stay server-side.
- PostgreSQL 18: Row Security Policies Checked 2026-10-11.
- With row security enabled and no policy a default-deny policy is used; USING applies to existing rows and WITH CHECK to rows being created or modified; superusers and roles with BYPASSRLS bypass row security and table owners normally do too.