Supabase

Supabase RLS: Preventing Authorization Bugs That Look Like Database Bugs

Learn how Supabase RLS works, why queries return no data, and how to create, debug, and secure Row Level Security policies in PostgreSQL.

Aarav Sharma

Aarav Sharma

September 14, 20269 min read
Share
Supabase RLS: preventing authorization bugs that look like database bugs

Introductions

A database query can be perfectly valid and still return no data. This can be confusing when the row clearly exists in your Supabase database. In many cases, the problem is not the SQL query or the database itself—it is Supabase Row Level Security (RLS).

Supabase RLS provides database-level authorization by controlling which rows a user can access. Instead of relying only on frontend checks, developers can define policies directly in PostgreSQL. These policies are automatically evaluated when a table is accessed.

In this guide, we will understand how Supabase RLS works, why authorization problems can look like database bugs, how to create RLS policies, and how to debug common issues using practical SQL examples.

Quick Answer: Why Does Supabase Return No Data When the Row Exists?

Supabase RLS can prevent a user from seeing a database row even when that row exists. An RLS policy is evaluated when the table is accessed and determines whether the current user is allowed to access that row.

For example, a policy such as user_id = auth.uid() allows a user to access only rows belonging to their authenticated user ID. If the IDs do not match, the query may return no rows even though the data exists.

What Is Supabase RLS?

Row Level Security (RLS) is a PostgreSQL feature that allows developers to control access to individual rows in a database table.

Supabase uses PostgreSQL RLS to provide granular authorization rules. A policy can define who can read, insert, update, or delete specific rows.

For example, suppose a tasks table contains:

ID**Titleuser_id
1Learn SQLuser-123
2Build APIuser-456

An RLS policy can ensure that user-123 can access only the first row.

This is important for applications where multiple users share the same database but should not automatically have access to each other's information.

Authentication vs Authorization

These two concepts are related but different.

Authentication answers:

Who is the user?

Supabase Auth handles this part.

Authorization answers:

What is this user allowed to access?

Supabase RLS can handle this at the database level.

The flow can be simplified as:

User
↓
Supabase Auth
↓
Authenticated Request
↓
PostgreSQL
↓
RLS Policy
↓
Allowed Rows

Supabase maps requests to database roles such as anon for unauthenticated requests and authenticated for authenticated requests.

Why RLS Bugs Look Like Database Bugs

Authorization bug that looks like a database bug: a request flows from the user through the Supabase API to PostgreSQL, where the RLS policy filters out rows belonging to other users

This is where RLS can become confusing during development.

Imagine that your application executes:

const { data, error } = await supabase
  .from("tasks")
  .select("*");

console.log(data);
console.log(error);

You expect to receive the user's tasks, but the result is:

data = []
error = null

You check the database manually and discover that the records definitely exist.

At this point, developers may start checking:

  • Whether the query is correct
  • Whether the database connection works
  • Whether the records were deleted
  • Whether the table contains the correct data

But another question is more important:

Is the current user authorized to see these rows?

Supabase describes RLS policies as similar to automatically adding a WHERE condition to a query. For example, a policy checking auth.uid() = user_id effectively limits the rows returned to those belonging to the current user.

A Simple Example

Suppose the table contains:

IDTitleuser_id
1Learn SQLuser-123
2Build APIuser-456

The logged-in user is user-123.

An RLS policy can effectively make a query behave like:

SELECT *
FROM tasks
WHERE user_id = auth.uid();

The second row exists, but it is not visible to user-123.

That is why an authorization problem can look like a missing database record.

How Supabase RLS Works

RLS policies are attached to database tables and are evaluated when those tables are accessed.

A policy can be created for specific operations:

  • SELECT
  • INSERT
  • UPDATE
  • DELETE

For example:

create policy "Users can view their own tasks"
on tasks
for select
to authenticated
using ((select auth.uid()) = user_id);

This policy says that authenticated users can read only rows where the user_id matches the ID of the current authenticated user. Supabase recommends explicitly specifying the target role with the TO clause.

Understanding auth.uid()

auth.uid() is a Supabase helper function that returns the ID of the user making the request.

For example:

(select auth.uid())

can be compared with a user_id column:

(select auth.uid()) = user_id

If the current user has ID user-123, only rows with:

user_id = user-123

will satisfy the policy.

When there is no authenticated user, auth.uid() returns null. Therefore, a policy comparing auth.uid() with a user ID will not match an ordinary user's row.

Understanding USING

USING determines which existing rows a user can access.

For example:

using ((select auth.uid()) = user_id)

means:

Allow access only when the row belongs to the current user.

This is commonly used with SELECT, UPDATE, and DELETE policies.

Understanding WITH CHECK

WITH CHECK validates the row being inserted or the resulting row after an update.

For example:

create policy "Users can create their own tasks"
on tasks

for insert

to authenticated

with check ((select auth.uid()) = user_id);

This prevents an authenticated user from inserting a task while assigning it to another user's ID.

The distinction is useful to remember:

USING → Which existing rows can I access?

WITH CHECK    →  What row values am I allowed to create or save?

Common Supabase RLS Authorization Bugs

RLS Is Enabled but No Policy Exists

If RLS is enabled and there is no policy allowing the requested operation, the application may not receive the expected data.

Always check the table's policies when a previously working query suddenly stops returning rows.

auth.uid() Does Not Match the User ID

A policy such as:

using ((select auth.uid()) = user_id)

depends on the user_id stored in the row matching the authenticated user's ID.

If those values are different, the row will not satisfy the policy.

The User Is Not Authenticated

If the request has no valid authenticated session, auth.uid() returns null.

Therefore, always verify that the user is actually logged in before debugging the database query itself.

The Wrong Policy Operation Is Being Used

A SELECT policy does not automatically give permission to insert or update data.

For example, your application may successfully read a task but fail when updating it because the corresponding UPDATE policy has not been created.

Confusing USING and WITH CHECK

Using the wrong condition can produce unexpected behavior.

Remember:

USING → controls which existing rows can be accessed

WITH CHECK  → controls which new/resulting rows are allowed

How to Debug Supabase RLS Problems

When a query returns unexpected results, follow this order:

1. Check whether the row exists

Confirm that the expected record actually exists in the database.

2. Check whether RLS is enabled

Verify the table's Row Level Security configuration.

3. Check the policies

Look for policies covering the operation you are performing.

4. Check the authentication state

Make sure the request is coming from the expected user.

5. Check auth.uid()

Compare the authenticated user's ID with the user_id stored in the database.

6. Check the policy condition

For example:

using ((select auth.uid()) = user_id)

Ask whether this condition is actually true for the requested row.

7. Test with another user

Testing with multiple accounts can quickly reveal whether the issue is related to authorization rather than the query itself.

Supabase also recommends automated database tests for RLS policies so that both allowed and denied access can be verified.

Practical RLS Example: User-Owned Data

Consider a task-management application where every task belongs to a user.

The policy is:

create policy "Users can view their own tasks"
on tasks
for select
to authenticated
using ((select auth.uid()) = user_id);

If User A has ID 111 and User B has ID 222:

Task 1 → user_id = 111

Task 2 → user_id = 222

When User A queries the table, the policy effectively checks:

111 = 111 → Allowed

111 = 222 → Not allowed

The database still contains both rows. RLS simply determines which rows the current user can access.

This is the key idea behind using RLS as a database-level authorization layer.

Supabase RLS Security Best Practices

Follow these practices when implementing RLS:

  • Enable RLS on tables exposed to your application.
  • Specify the intended role using TO authenticated or TO anon.
  • Use auth.uid() for user-owned records.
  • Create policies for each operation your application actually needs.
  • Do not rely only on frontend authorization checks.
  • Never expose secret or service-level keys in browser code.
  • Test both allowed and denied access.

Supabase notes that service-role access bypasses RLS and should therefore remain on the server rather than being exposed to customers or browser applications.

Supabase RLS Performance Considerations

RLS provides strong authorization, but policy design can also affect query performance.

One important recommendation is to index columns frequently used in RLS conditions.

For example, if your policy checks:

using ((select auth.uid()) = user_id)

an index on user_id can help PostgreSQL evaluate the policy efficiently:

create index tasks_user_id_idx
on tasks using btree (user_id);

Supabase also recommends wrapping functions such as auth.uid() in a SELECT expression where appropriate, allowing PostgreSQL to cache the result for the statement rather than repeatedly evaluating it for every row.

For large applications, test RLS performance with realistic data rather than assuming that every policy will have the same impact.

When Should You Use Supabase RLS?

Supabase RLS is especially useful when an application contains user-specific or sensitive data.

Common examples include:

  • SaaS applications
  • Task-management applications
  • User profiles
  • Private documents
  • Customer records
  • Orders and transactions
  • Team-based applications
  • Multi-tenant applications

If multiple users share the same database but should have different access to rows, RLS provides a strong database-level authorization mechanism.

Frequently Asked Questions

What is RLS in Supabase?

RLS stands for Row Level Security. It allows you to define PostgreSQL policies that determine which rows users can access.

Why does Supabase return an empty array?

One possible reason is that RLS is filtering the rows because the current user does not satisfy the policy. Check whether RLS is enabled, whether the correct policy exists, and whether auth.uid() matches the relevant user ID.

Does authentication automatically give users database access?

No. Authentication identifies the user, but database authorization determines what that user can access. In Supabase, RLS policies can be used to enforce that authorization at the database level.

What is the difference between USING and WITH CHECK?

USING determines which existing rows a request can access. WITH CHECK validates the rows being inserted or the resulting rows after an update.

Can RLS replace frontend authorization?

RLS should be responsible for protecting database access. Frontend authorization can still control what users see in the interface, but frontend checks should not be treated as the database's security boundary.

Conclusion

Supabase RLS can make database authorization much safer, but it can also make debugging confusing when a query returns no data even though the record exists.

The important thing to remember is that a successful query does not necessarily mean the user is authorized to see every matching row. RLS policies are evaluated at the database level and can restrict access based on the authenticated user's identity, role, or other conditions.

When debugging unexpected Supabase results, don't look only at the query or database records. Check whether RLS is enabled, which policy applies, whether the user is authenticated, and whether auth.uid() matches the data being protected.

Once you understand these concepts, many "database bugs" become much easier to identify as what they really are: authorization problems.

Aarav Sharma

Aarav Sharma

Lead Software Engineer

Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.

Related articles

Let's Collaborate

Tell us about your project and we'll come back with a plan, a timeline, and a quote.

Project Type

Budget

Task Message

Your Contacts