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

 


Some Supabase bugs look like frontend problems.

A form submits but no row appears. A query returns an empty array even though the table contains data. An update works in the dashboard but fails inside the application. The natural reaction is to keep changing the React component or API call.

Sometimes the real issue is Row Level Security.

This is closely connected to a lesson from Authentication Bugs That Look Like Frontend Problems: signing in proves who a user is, but it does not automatically give that user permission to access every database row.

Row Level Security, usually called RLS, decides which database rows a request can read, insert, update or delete. It is an important protection, but a policy can fail quietly when I misunderstand the authenticated user, the row data or the operation being performed.

This is the process I use to debug it.

First, I identify the exact operation

I do not begin with “the database is not working.” I write down the specific action:

  • SELECT: reading rows;

  • INSERT: creating a row;

  • UPDATE: changing an existing row;

  • DELETE: removing a row.

Policies are operation-specific. A working SELECT policy does not give the user permission to INSERT. An UPDATE may also need permission to see the existing row before changing it.

Knowing the exact operation prevents me from editing the wrong policy.

Then I confirm the user is actually authenticated

Many policies compare a row's owner ID with auth.uid(). If the request has no valid user session, auth.uid() is null and the comparison will not pass.

In the application, I check the current session immediately before the failing request. I also confirm that the Supabase client making the query is the authenticated client, not a separate instance that lost the session.

This is especially important with server rendering. Code may run in the browser, on the server or in an API route, and each environment needs the correct authentication context.

I never fix a missing session by placing the service-role key in frontend code. That key bypasses RLS and must remain on a trusted server.

I inspect the row ownership value

A common policy looks conceptually like this:

auth.uid() = user_id

That rule only works if user_id is filled with the authenticated user's ID.

For an insert, I inspect the data being sent. Is user_id present? Is it the correct UUID? Is the column accidentally using an email address or profile-table ID instead of the authentication ID?

I also check the database type. Comparing a UUID with text can create problems or encourage unsafe casting.

I understand USING and WITH CHECK

These two policy expressions answer different questions.

  • USING controls which existing rows the user is allowed to access for an operation.

  • WITH CHECK controls whether the new row values are allowed after an INSERT or UPDATE.

For an update, I may need both. The user must be allowed to select the existing row, and the changed row must still satisfy the ownership rule.

A safe ownership policy should not allow a user to update user_id and transfer a row to somebody else unless that behaviour is intentional.

I reproduce the smallest failing request

Large forms hide useful information. I reduce the query to the smallest request that should work.

For an insert, I send only the required columns. For a select, I request one known row. I log the returned data, error, status and status text during development.

I do not display raw database errors to public users, but seeing the complete development error helps me separate:

  • an RLS rejection;

  • a missing required column;

  • an invalid data type;

  • a unique-constraint failure;

  • application code that never sent the request.

I test roles separately

The Supabase dashboard's SQL editor may run with privileges that my application user does not have. A query succeeding there does not prove the policy works for an authenticated or anonymous request.

I test the real user journey with RLS enabled. If the product supports both public visitors and signed-in users, I test both roles because their permissions should be different.

I also create at least two test users. A policy is not properly tested if I only prove that User A can see User A's data. I also need to prove that User A cannot see User B's private rows.

I avoid the tempting unsafe fixes

When RLS blocks development, three shortcuts are tempting:

  1. disabling RLS;

  2. creating a policy that allows every action for everyone;

  3. moving a service-role key into the browser.

These changes may make the error disappear while creating a much bigger security problem.

Instead, I write the narrowest policy that supports the real action. Public content can have a public SELECT policy. Private user records should check ownership. Administrative actions should happen through trusted server code with additional authorization.

My RLS debugging checklist

When a Supabase query fails, I check:

  1. Which operation is failing?

  2. Is RLS enabled on the correct table?

  3. Does a policy exist for this operation and role?

  4. Does the request contain a valid session?

  5. What does auth.uid() represent in this request?

  6. Does the row's ownership column contain that same user ID?

  7. Are USING and WITH CHECK both correct for the operation?

  8. Does the request fail because of a constraint rather than RLS?

  9. Can one test user access another user's private row?

  10. Have I kept the service-role key out of public code?

RLS becomes easier to reason about when I stop treating it like a mysterious wall. It is a set of database rules evaluating a specific user, operation and row.

The goal is not only to make the query work. The goal is to make the correct query work for the correct person—and fail safely for everyone else.

Post a Comment

Previous Post Next Post

Contact Form