Supabase Says “Permission Denied”: How I Debug Row Level Security Without Guessing

 

A form can look fine and still fail at the database. The button clicks, the request goes out, then Supabase returns an error or an empty result.

When that happens, I want to find out what the request is allowed to do before I start changing policies. Opening access until the error disappears is not a fix I can trust.

Start with the actual operation

I write down the table, the signed-in user and the operation: SELECT, INSERT, UPDATE or DELETE. A policy for reading rows does not automatically allow inserting them. Updating can also need a SELECT policy so the row is visible to the request.

I check the returned error object instead of only logging the data. A missing table grant, a constraint failure and an RLS restriction are different problems. An empty SELECT result can mean RLS hid the rows; it does not always arrive as a permission error.

Check which user made the request

A page can show a signed-in screen while the request is being made without the session I expected. I check that the correct client and project are being used and that authentication has finished loading.

In a policy, auth.uid() refers to the authenticated user's ID. Without an authenticated user it returns null. For an ownership rule, the row's owner field needs to hold the matching auth user ID, not an email or an unrelated profile ID.

I covered the other side of this in authentication bugs that look like frontend bugs. A route guard controls the screen. The database still needs its own authorization rules.

Read the policy against a real row

A common ownership expression is:

auth.uid() = user_id

That only makes sense if user_id is the ownership column in this schema. I check its type and the actual value being inserted. Copying the expression is not enough.

USING controls which existing rows can be accessed. WITH CHECK checks proposed row values for inserts and updates. For an update, I need to consider both the existing row and what the user is allowed to change it into. Otherwise an ownership change can slip through a poorly designed rule.

I also check table grants and the roles targeted by the policy. Granting access to an authenticated role is not the same as limiting that user to their own records.

Make the request small enough to inspect

I reduce the query to one known row and the fields I need. Then I check the result and error. I avoid putting tokens, personal records or secret keys into logs.

A query working in the SQL editor is useful, but it does not prove the browser user has permission. The SQL editor can run with privileges different from the client request.

Test the person who should be refused

I test signed out, signed in as the owner, and signed in as another user. The owner should get the access the product promises. The second user should not gain access to private rows by changing an ID in a request.

I repeat that for reading, adding, editing and deleting where those actions exist. One successful read does not verify the whole table.

Keep the fix narrow

I do not put a service-role or secret key into browser code to bypass the problem. I do not leave RLS disabled or use an allow-everything policy as the final answer.

Public content, private records and admin actions can need different rules. I write the rule around the product's actual access model, then test it through the same client path users will use.

The question I keep coming back to is simple: who is making this request, and which row should they be allowed to touch?

Supabase's RLS documentation explains the policy details. I check that when the behavior is unclear instead of guessing at SQL until a request passes.

Post a Comment

Previous Post Next Post

Contact Form