← Back to blog

Supabase RLS Changed How I Think About Application Authorization

Split illustration comparing application-layer authorization with database-enforced row access, showing permitted and blocked records.

While building DocumentHub, I found myself looking at a small piece of Supabase code and asking a larger architectural question:

const { error } = await supabase
  .from('documents')
  .update({ status: 'archived' })
  .eq('id', documentId)
  .eq('user_id', user.id);

If Row Level Security already guarantees that I can only update my own documents, do I still need .eq('user_id', user.id)?

The question was not really about one filter. It was about where authorization lives.

The authorization model I was used to

Most of my career has been spent building backend services, APIs, and web applications with C#/.NET, React, Node.js, AWS, Azure, and SQL Server. In that world, I usually thought about authorization as something the application enforced before it reached the database:

Browser
   ↓
API
   ↓
Authentication
   ↓
Authorization checks
   ↓
Service / Repository
   ↓
Database

A typical query might look like this:

var document = await db.Documents
    .Where(x => x.Id == documentId)
    .Where(x => x.UserId == userId)
    .SingleAsync();

The service gets the authenticated user's ID, includes it in the query, and passes the already-scoped operation to the database. This architecture is normal and not inherently wrong. It is still the right choice for many applications.

For most of my career, though, I thought of the database as the place where already-authorized application code stored and retrieved data—not as another authorization boundary.

What RLS changes

PostgreSQL Row Level Security changes that boundary. A policy is attached to a table, and PostgreSQL evaluates it when the table is accessed. A representative policy for updating documents might look like this:

create policy "Users can update their own documents"
on public.documents
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

One prerequisite is easy to miss: a policy does not grant table privileges. RLS must be enabled on the table, and the anon and authenticated grants must be deliberately configured for the operations the application exposes. PostgreSQL checks those table privileges before it evaluates a policy, and adding a policy does not remove existing grants. This article assumes those prerequisites are already in place; it is not a setup tutorial.

The syntax is not the important part here. The important part is that the database has its own rule about which rows an authenticated caller can update.

The mental model becomes closer to this:

Application describes the operation it wants
   ↓
Database independently applies authorization rules
   ↓
Only permitted rows are accessible

The application still chooses the operation. It still needs to authenticate the caller, validate input, handle errors, and apply business rules. But a fundamental data-ownership rule no longer exists only in application code.

Filtering and authorization are different questions

This distinction helped me understand the original question.

.eq('user_id', user.id)

is an application filter. It says:

These are the rows my application intends to operate on.

The policy expression:

auth.uid() = user_id

answers a different question:

These are the rows the caller is actually allowed to operate on.

If RLS already enforces ownership, the application filter may not be required as the security boundary. But it can still be useful. It makes query intent visible, limits the set of rows the query asks the database to consider, and provides a defensive application-level constraint.

That is why I am keeping both in DocumentHub. They do not make the system “twice as secure” in a simple additive sense. The filter and the policy express related rules at different boundaries.

This is also why Supabase recommends explicit filters alongside RLS instead of relying on the policy as the only row restriction. The RLS documentation describes policies as implicit WHERE clauses and recommends adding a filter to help PostgreSQL construct a better query plan.

The practical conclusion for me is:

  • Keep the application filter when it communicates the operation clearly.
  • Treat RLS as the independent authorization boundary.
  • Do not mistake an application filter for a replacement for RLS.

USING and WITH CHECK in practical terms

Two policy clauses initially made more sense when I stopped treating them as SQL vocabulary and started treating them as two separate questions:

USING
"Am I allowed to operate on this existing row?"

WITH CHECK
"Is the resulting or new row allowed?"

In broad terms:

  • SELECT asks which existing rows the caller may see, so it uses USING.
  • INSERT asks whether a new row is allowed, so it uses WITH CHECK.
  • UPDATE needs both: can the caller update the existing row, and is the resulting row still allowed?
  • DELETE asks which existing rows the caller may delete, so it uses USING.

For an update, checking both sides matters. Without the WITH CHECK condition, a user might be allowed to update one of their rows and change its user_id to somebody else's ID. The existing row was owned by the caller, but the resulting row would not be.

There is another detail that is easy to miss: an update may need applicable SELECT access because PostgreSQL has to read the row as part of the operation. In Supabase, an update policy by itself is not necessarily enough for an update to work as expected.

I do not need to memorise every policy variation to use this model. I need to ask whether a rule applies to an existing row, a new row, or both.

Where does auth.uid() come from?

The other part that felt unusual was auth.uid().

My traditional model was:

Request
   ↓
API authenticates caller
   ↓
Application gets user ID
   ↓
Application passes user ID into query
   ↓
Database

With Supabase, the flow is closer to:

Authenticated request
        ↓
Supabase client / API layer
        ↓
JWT-derived request context
        ↓
PostgreSQL
        ↓
RLS policy evaluates auth.uid()

PostgreSQL is not managing the user's login session directly. Supabase makes the authenticated request context available to PostgreSQL, and the policy can evaluate the caller identity from that context. auth.uid() returns the ID associated with the request; it is not a database lookup of a user session that PostgreSQL created itself.

That difference is small in code but significant in architecture. The database can make an authorization decision without trusting an arbitrary user ID supplied as a query parameter by application code.

The file needs its own boundary

DocumentHub protects two related but separate resources:

PostgreSQL
└── document metadata

Supabase Storage
└── uploaded file

Protecting the metadata row does not automatically protect the file. Storage has its own access-control policies on storage.objects.

DocumentHub stores files in a documents bucket with the user's ID as the first path segment. Conceptually, the paths look like this:

documents bucket
└── <user-id>/
    └── <generated-file-name>.pdf

The folder structure is not security by itself. It gives the Storage policy useful ownership information. The policy still has to check the bucket, the path, and the caller's identity. A policy that protects metadata but allows any authenticated user to read or delete any Storage object would leave the actual file exposed.

The same separation applies to signed URLs. A signed URL is another access path that needs to be handled carefully; protecting a database row does not revoke or constrain a URL that has already been leaked.

RLS changes the failure mode

Without database-enforced authorization, a missing ownership filter can have a wide blast radius:

Application bug
      ↓
Ownership filter missing
      ↓
Broader database query
      ↓
Potential cross-user data exposure

With correctly designed RLS, the failure looks different:

Application bug
      ↓
Ownership filter missing
      ↓
Query is broader than intended
      ↓
RLS still limits accessible rows

That is a valuable property. RLS did not make DocumentHub automatically secure. What it did was make one category of application mistake less catastrophic.

What RLS does not protect against

RLS is not a substitute for thinking about the rest of the system. It does not protect against:

  • a badly written or overly broad policy;
  • an incorrect Storage policy;
  • leaked signed URLs;
  • business rules that are not represented by RLS;
  • authorization mistakes outside PostgreSQL;
  • privileged backend access that bypasses RLS; or
  • application workflow mistakes.

The security logic did not disappear. Some of it moved closer to the data.

For example, a rule such as “a user can only access documents they own” fits naturally in a database policy. A rule such as “a finance manager can approve an invoice only after two previous workflow steps have completed” probably still belongs mainly in application logic. It involves a workflow and a business decision, not only row ownership.

Privileged backend operations are a different path

The RLS boundary only helps when a request actually passes through it.

A normal authenticated request is conceptually:

Normal authenticated request
        ↓
RLS applies
        ↓
User-scoped access

Some backend processes need broader access for administrative or system-level work. Supabase's current server-only secret key authorizes access through the service_role PostgreSQL role, which has the BYPASSRLS attribute. The JWT-based service_role API key is the legacy form; new server-side code should prefer a secret key. A process using that role is not protected by the user-level policies in the same way:

Privileged backend process
        ↓
service_role
        ↓
RLS bypassed
        ↓
Backend code is responsible for access control

That is why a secret key belongs only on a trusted server and why server-side code is not automatically safe just because it does not run in a browser. The backend process needs its own authorization decisions, input validation, and careful handling of which operations it exposes. There is one important nuance: a secret key bypasses RLS only when the request carries no user access token. If it carries a signed-in user's access token, Supabase evaluates the request under that user's RLS policies even if the client was initialized with a secret key. See Supabase's RLS guidance on bypassing policies.

RLS is powerful only when requests actually pass through the security boundary I designed.

Two mental models

The contrast is clearer when I put the two models next to each other.

The traditional model:

Browser
   ↓
API
   ↓
Authentication
   ↓
Authorization code
   ↓
Service / Repository
   ↓
Database

Supabase with RLS:

Browser / Server Component / Server Action
                  ↓
           Supabase client
                  ↓
             PostgreSQL
                  ↓
           RLS evaluates caller
                  ↓
             Allowed rows

The second model does not mean the application no longer needs authorization or business rules. It means some fundamental data-ownership rules can be enforced independently at the database boundary.

That distinction matters in a Next.js application too. A Server Component or Server Action is still application code running on the server. It still needs authentication, authorization for the operation, input validation, and appropriate error handling. The database policy is an additional boundary, not permission to stop doing those things.

Choosing architecture without repeating old layers

One reason I built DocumentHub was to learn a faster, freelancer-friendly approach to building applications rather than automatically reproducing the enterprise-style layers I have used in larger systems.

Historically, I might have introduced repositories, authorization services, domain services, and several defensive layers to express that a user can only access rows they own. Those layers can be justified in a larger system, particularly when they contain reusable domain behaviour or support multiple clients.

For a small SaaS application, I do not necessarily want to recreate them solely out of habit when the platform already provides a strong boundary for a simple ownership rule.

This is not an argument for eliminating application architecture. It is an argument for distinguishing between a fundamental data-access rule and a more complex business rule. I want to put each rule where it can be expressed clearly and enforced reliably.

The question I ask now

The biggest thing I learned from Supabase RLS was not how to write policies. It was learning to separate two questions:

What data does my application intend to access?

and:

What data is this caller actually authorized to access?

Application filters answer the first question. RLS can independently answer the second.

That has changed how I think about authorization. I still write the explicit filter when it communicates intent, and I still keep authorization and business rules in application code where they belong. But I no longer assume that the database must only be a passive destination for already-authorized queries.

It can be an independent boundary—and, for simple ownership rules, that is a useful boundary to have.

About DocumentHub

DocumentHub is my Next.js and Supabase learning project and portfolio piece. The source code includes the document actions and the database migrations that define the document and Storage policies.