back to thoughts

    Supabase RLS From Day One: How I Lock Down Every Project

    Practical Supabase Row Level Security patterns I use on real projects: owner access, teams, roles, storage, testing, and the mistakes that leak data.

    11 min read
    SupabaseRow Level SecurityRLSPostgreSQLAuthMulti-tenantSecurityNext.js

    Every couple of months, a founder asks me to fix a Supabase project that already has users in it.

    The symptoms vary. Users can see other users' data. Admin screens are protected in the UI but not in the database. Storage buckets are quietly public. A table was added during a late-night sprint and nobody remembered to turn on Row Level Security.

    The root cause is usually the same: RLS was treated like a cleanup task.

    I get why it happens. When you are moving fast on an MVP, security feels like something you can circle back to. You probably will not. Or by the time you do, there is a production database with 50 tables, three kinds of users, a billing integration, and no clear memory of which rows belong to whom.

    That is how a 5-minute habit becomes a weekend rescue mission.

    So my rule is simple: I enable RLS the moment I create a table.

    No "after the feature works." No "before launch." No "once the schema settles." From the first table.

    Supabase makes it easy to access Postgres directly from the browser, which is one of the reasons I like it for MVPs. But that also means the database has to be the security boundary. Your React components are not a security boundary. A hidden button is not a security boundary. A protected route is not enough.

    RLS is where the data gets locked.

    What RLS actually does

    Row Level Security is a Postgres feature. Supabase did not invent it; Supabase gives you a very convenient way to combine it with Auth.

    The simplest way to think about RLS is this:

    Every query gets an invisible where clause.

    If a user selects projects, the database asks:

    "Which project rows is this user allowed to see?"

    If a user updates a profile, the database asks:

    "Is this user allowed to update this row, and is the new row still valid after the update?"

    That second part matters. In Postgres policies, using controls which existing rows a user can access. with check controls which new or changed rows they are allowed to create.

    This distinction is where a lot of broken policies come from.

    My baseline: enable RLS before writing policies

    For any table in an exposed schema, especially public, I start here:

    alter table public.profiles enable row level security;

    Once RLS is enabled, the API cannot access rows through the anon/public key until policies allow it. That is the posture I want: closed first, opened intentionally.

    For a basic profiles table:

    create table public.profiles (
      id uuid primary key references auth.users(id) on delete cascade,
      display_name text,
      avatar_url text,
      created_at timestamptz not null default now()
    );
    
    alter table public.profiles enable row level security;

    Then I add a read policy:

    create policy "Users can read their own profile"
    on public.profiles
    for select
    to authenticated
    using ((select auth.uid()) = id);

    And an update policy:

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

    Two small details are doing real work here.

    First, the policy is scoped to authenticated. That keeps it from even running for anonymous requests.

    Second, I use (select auth.uid()) instead of calling auth.uid() bare. Supabase recommends this pattern in their RLS performance guidance because it can help Postgres optimize policy evaluation.

    Tiny thing. Worth doing.

    Insert policies need with check

    This is one of the most common mistakes I see.

    A developer writes a select policy, maybe an update policy, and then wonders why inserts fail. Or worse, they make inserts too permissive to get unstuck.

    For a table where each row belongs to a user:

    create table public.notes (
      id uuid primary key default gen_random_uuid(),
      user_id uuid not null references auth.users(id) on delete cascade,
      title text not null,
      body text,
      created_at timestamptz not null default now()
    );
    
    alter table public.notes enable row level security;

    The policies:

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

    The insert policy does not use using because there is no existing row yet. It uses with check to say: "The row being inserted must belong to the current user."

    The update policy uses both. The user must be allowed to access the old row, and the new version of the row must still belong to them.

    That prevents a subtle bug where a user updates user_id and transfers a row into someone else's account.

    The multi-tenant pattern I reach for first

    Most SaaS apps eventually stop being "one user owns one thing."

    They become:

    • users
    • organizations
    • memberships
    • roles
    • resources that belong to an organization

    I usually start with this shape:

    create table public.organizations (
      id uuid primary key default gen_random_uuid(),
      name text not null,
      created_at timestamptz not null default now()
    );
    
    create table public.memberships (
      organization_id uuid not null references public.organizations(id) on delete cascade,
      user_id uuid not null references auth.users(id) on delete cascade,
      role text not null check (role in ('owner', 'admin', 'member')),
      created_at timestamptz not null default now(),
      primary key (organization_id, user_id)
    );
    
    create table public.projects (
      id uuid primary key default gen_random_uuid(),
      organization_id uuid not null references public.organizations(id) on delete cascade,
      name text not null,
      created_at timestamptz not null default now()
    );
    
    alter table public.organizations enable row level security;
    alter table public.memberships enable row level security;
    alter table public.projects enable row level security;

    For organization-scoped resources, I want a policy that says:

    "You can see this project if you are a member of the organization that owns it."

    create policy "Members can read organization projects"
    on public.projects
    for select
    to authenticated
    using (
      organization_id in (
        select memberships.organization_id
        from public.memberships
        where memberships.user_id = (select auth.uid())
      )
    );

    For writes, I usually require owner or admin:

    create policy "Admins can update organization projects"
    on public.projects
    for update
    to authenticated
    using (
      organization_id in (
        select memberships.organization_id
        from public.memberships
        where memberships.user_id = (select auth.uid())
        and memberships.role in ('owner', 'admin')
      )
    )
    with check (
      organization_id in (
        select memberships.organization_id
        from public.memberships
        where memberships.user_id = (select auth.uid())
        and memberships.role in ('owner', 'admin')
      )
    );

    It is a little verbose. I prefer verbose and boring here.

    You can clean this up with security-definer helper functions later, but I do not start there unless the policies are becoming hard to read or slow. When you do use security-definer functions, keep them out of exposed schemas and treat them like sharp tools.

    Do not put authorization in raw_user_meta_data

    Supabase gives you auth.jwt() for reading JWT claims in policies. That can be useful, but you need to be careful about where the data comes from.

    User-editable metadata is not a safe place for authorization.

    If a user can update a field, I do not want that field deciding whether they are an admin. For authorization data in JWT claims, Supabase distinguishes between user metadata and app metadata. App metadata is the safer place for authorization claims because users cannot update it directly.

    Even then, for most app permissions, I still prefer a membership table. It is easier to query, easier to audit, and easier to change when a user leaves an organization.

    Storage needs policies too

    Supabase Storage can also bite you if you treat it as separate from the database.

    For user-owned uploads, I usually make the path include the user id:

    avatars/{user_id}/profile.png

    Then the policy checks the folder name:

    create policy "Users can upload their own avatars"
    on storage.objects
    for insert
    to authenticated
    with check (
      bucket_id = 'avatars'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    );
    
    create policy "Users can update their own avatars"
    on storage.objects
    for update
    to authenticated
    using (
      bucket_id = 'avatars'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    )
    with check (
      bucket_id = 'avatars'
      and (storage.foldername(name))[1] = (select auth.uid())::text
    );

    For public assets, I make that decision explicit. Public read is fine for a blog image bucket. It is not fine for invoices, resumes, user documents, or anything a customer uploaded assuming it was private.

    How I test RLS before trusting it

    I do not trust a policy because it looks right.

    I test with at least two users.

    The manual version:

    1. Create User A and User B.
    2. Create data owned by User A.
    3. Log in as User B.
    4. Try to read, update, and delete User A's data.
    5. Make sure the UI handles "no rows" cleanly.

    That last step is not cosmetic. When RLS blocks access, Supabase often returns no rows rather than a dramatic "security policy blocked you" message. Your UI has to distinguish between "there is no data yet" and "something is broken."

    For multi-tenant apps, I test:

    • user in no organizations
    • user in one organization
    • user in two organizations
    • member trying admin-only actions
    • removed member trying old links
    • direct API calls, not just UI clicks

    RLS has to protect the database even when someone skips the interface.

    RLS with Next.js server components

    In a Next.js App Router app, I usually access Supabase in two places:

    • Server Components, Server Actions, or Route Handlers for server-side work
    • Client Components for interactive browser behavior

    Supabase's current Next.js guidance uses @supabase/ssr for cookie-based auth. That is the pattern I reach for because the server can read the user's session from cookies and make authenticated requests without exposing secret keys.

    The important thing: using Supabase on the server does not automatically mean "bypass RLS."

    If you create a normal user-scoped Supabase client with the user's session, RLS still applies. That is what you want for most app queries.

    The service role key is different. It bypasses RLS. I only use it in tightly controlled server-only paths where bypassing RLS is the point: webhooks, admin maintenance, background jobs, or system tasks.

    And I never put it in client-side code. Ever.

    If your service_role key is available in the browser, RLS is no longer your security layer. It is decoration.

    The mistakes I keep seeing

    The same mistakes show up over and over:

    • RLS enabled on main tables but not on join tables.
    • select policies written, but inserts and updates forgotten.
    • with check missing on updates.
    • UPDATE or DELETE policies written without a matching SELECT policy. The operation silently returns zero rows with no error, which looks like a data bug instead of a permissions bug.
    • Admin status stored in user-editable metadata.
    • Service role key used to "fix" client-side permission errors.
    • Public storage buckets used for private files.
    • Policies that work for one user but fail for teams.
    • UI route protection treated as database protection.
    • No test with a second user.

    None of these make you a bad developer. They are easy mistakes to make when the product is moving fast.

    But they are also exactly the kind of mistakes that become expensive later.

    My default rule

    If a table stores user data, enable RLS immediately.

    If a row belongs to a user, put the owner id on the row.

    If a row belongs to a team, use memberships.

    If a user can write a row, use with check.

    If a file is private, write storage policies before upload UI.

    If the service role key is involved, slow down.

    RLS is not the feature people pay for. Nobody opens your app and says, "Wow, beautiful policies."

    But RLS is what lets the rest of the product be trusted.

    And trust is much easier to build from day one than to retrofit after the wrong person sees the wrong row.


    Sources and further reading

    👨‍💻

    Ryan Katayi

    Full-stack developer who turns coffee into code. Building things that make the web a better place, one commit at a time.

    more about me