Muhammad Mustafa logo

July 17, 2026 · 5 min read

Supabase RLS for Multi-Role Apps: What I Learned

Supabase Row Level Security diagram separating admin, coach, and team database access

Supabase Row Level Security is the part of a multi-role app that decides which database rows each user can read or change. In BWL League, the platform serves coaches, teams, and admins. Those users share the same application, but they should not share the same level of access. I learned that hiding an admin button in React is useful for the interface, but it is not security. The real boundary has to exist in the database.

This article explains how I think about Supabase RLS for a multi-role app: start with ownership, separate actions, keep privileged access off the client, and test policies as carefully as application code.

Why Supabase RLS belongs in the database

A frontend role check only changes what a user sees. A curious user can still inspect network requests or call an API endpoint directly. Row Level Security, usually shortened to RLS, adds the authorization rule inside PostgreSQL. Every database request is filtered through that rule.

Supabase describes a policy as something similar to an automatic WHERE clause. If a coach may only read rows for their own team, the database applies that condition even when the client asks for every row. That gives the app a second and more reliable security boundary.

This matters because Supabase applications can query the database directly from the browser. The public client key identifies the project; it is not supposed to grant unlimited access. The user's session and the RLS policies decide what the request can actually do. Supabase recommends enabling RLS on every table in an exposed schema such as public.

Start with relationships, not role names

It is tempting to begin with statements such as “admins can do everything” and “coaches can edit scores.” Those statements are incomplete until the data model explains which league, team, or match the user belongs to.

For a league platform, I prefer to make membership explicit. A membership row can connect a user to an organisation or team and store the role they hold there. Policies can then answer two separate questions:

  • Scope: Does this row belong to the user's league or team?
  • Permission: Does the user's role allow this action?

Keeping those questions separate makes policies easier to review. A coach might be allowed to update a match, but only when that match belongs to their team. An admin might manage the whole league, but not a different league that happens to use the same database.

Here is a simplified example for reading matches. The exact table names will vary, but the important part is the relationship check rather than trusting a role sent by the browser:

alter table public.matches enable row level security;

create policy "members can view league matches"
on public.matches
for select
to authenticated
using (
  exists (
    select 1
    from public.memberships m
    where m.user_id = (select auth.uid())
      and m.league_id = matches.league_id
  )
);

The database gets the user ID from the authenticated session with auth.uid(). The client does not get to choose another user ID and claim their access.

Write a policy for each action

Reading, inserting, updating, and deleting are different permissions. I avoid one broad policy when the product rules are different for each operation.

Use USING for rows that already exist

A USING expression decides which existing rows are visible for operations such as SELECT, UPDATE, and DELETE. If the expression is false, PostgreSQL behaves as if that row is outside the user's allowed dataset.

Use WITH CHECK for the resulting data

A WITH CHECK expression validates a row being inserted or the result of an update. This prevents a valid user from changing an allowed row so that it moves into another team's scope.

For updates, I normally want both checks: one to confirm that the user may touch the existing row and another to confirm that the new values remain valid. Supabase also notes that an update needs a corresponding SELECT policy. Missing that read policy can make a reasonable-looking update policy fail in confusing ways.

Delete access deserves its own careful decision. A user who can edit a score does not automatically need permission to delete the match. Smaller permissions reduce both security risk and accidental damage.

The mistakes I actively check for

Leaving one exposed table without RLS

A strong policy on the main table does not protect a related table that has no policy. I treat every exposed table, view, and storage bucket as a separate review item. Views need special attention because their execution permissions can bypass the underlying table policies unless they are configured correctly. On supported PostgreSQL versions, a security_invoker view can respect the caller's permissions.

Putting the service key in frontend code

The Supabase service role is intentionally powerful and bypasses RLS. It belongs only in trusted server-side code or controlled automation. Shipping it in a browser bundle removes the protection the policies were meant to provide.

Testing only the successful role

A policy is not proven because an admin can complete an action. I test the negative cases too: a coach from another team, a signed-in user with no membership, an anonymous request, and a user attempting to change the ownership field during an update.

I also test each database operation independently. A passing SELECT test says nothing about INSERT or DELETE. For important rules, I want a small access matrix that lists roles against actions and expected results. It turns vague authorization requirements into checks the team can repeat after a schema change.

The practical takeaway

Supabase RLS works best when it is treated as part of the product model, not as a final security switch. Define who owns each row, connect users to that scope, give every operation the smallest permission it needs, and make denied-access tests part of development.

That approach has been a natural fit for BWL League, where coaches, teams, and admins use the same live platform with different responsibilities. You can see the project in my portfolio. For the exact PostgreSQL syntax and current platform guidance, I also recommend the official Supabase RLS documentation and its role-based access control guide.

If you are building a multi-role SaaS and want help designing the data model and access rules before they become difficult to change, contact me.

Written by Muhammad Mustafa — Full-Stack SaaS Engineer

Get in touch