Web Development
Next.jsSupabasePostgreSQLRow Level SecurityMulti-tenantSaaSSecurityApp Router

Next.js + Supabase RLS for Multi‑Tenant SaaS: Policies, Roles, and Safe Data Access

AO
Adrijan Omićević
·15 min read

# What You’ll Build#

This guide shows a practical, production-grade strategy for Next.js Supabase row level security multitenant apps using the Next.js App Router. You’ll design tables, implement RLS policies with roles, and use safe server-side access patterns that keep tenant data isolated even when your queries get complex.

You’ll also learn how teams accidentally break isolation with service role misuse and leaky joins, plus a deployment checklist you can copy into your runbook.

For broader context on multi-tenant system design, see our architecture deep dive: Next.js multi-tenant SaaS architecture guide. If you’re still deciding on auth, this guide pairs well with: Next.js authentication guide for NextAuth, Clerk, and Supabase. For an end-to-end baseline, see: Next.js Supabase SaaS starter architecture.

# Multi-Tenant RLS Strategy in One Page#

There are two common tenancy models:

ModelDescriptionProsConsBest for
Single database, shared tablesEvery row has tenant_id (or organization_id) and RLS isolates accessSimple ops, lower cost, easy analyticsPolicies must be correct everywhereMost B2B SaaS
Database per tenant (or schema per tenant)Each tenant has separate DB or schemaStrong isolation boundaryComplex ops, migrations, costRegulated or very large tenants

This guide focuses on shared tables + strict RLS, because it’s the typical Supabase fit and scales well until you hit enterprise isolation requirements.

A robust approach has three pillars:

  1. 1
    Tenant membership is stored in the database, not hardcoded in JWT claims.
  2. 2
    Every tenant-scoped table has tenant_id and RLS enabled.
  3. 3
    App code never “filters by tenant” as the primary security boundary. The database is the boundary.

🎯 Key Takeaway: Your app’s WHERE tenant_id = ... is for performance and clarity, not security. RLS is the security control.

# Prerequisites#

RequirementVersionNotes
Next.js14+ or 15+App Router patterns used
SupabasePostgres 15+ (managed)RLS, auth, policies
supabase-js2.xServer-side client patterns
AuthSupabase Auth or external providerMust provide a valid user JWT to Supabase

If you use Clerk or NextAuth, your integration must still end with Supabase receiving a JWT it can validate as the request user. If that’s not true, your “RLS” becomes “trust the app server,” which defeats the point.

# Table Design for Multi-Tenant SaaS#

Core Entities#

A common B2B SaaS data model uses organizations, memberships, and tenant-scoped resources.

TablePurposeTenant-scopedNotes
organizationsTenant recordYesOne row per tenant
organization_membersUser belongs to organization, with a roleYesYour primary authorization primitive
projectsExample resourceYesHas organization_id
ticketsExample resourceYesHas organization_id and maybe project_id
profilesUser profile dataNoUsually 1 row per user, not tenant-specific

SQL Schema Example#

Keep the schema explicit: organization_id everywhere, strict FK constraints, and stable IDs.

SQL
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.organization_members (
  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_by uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);
 
create index projects_org_idx on public.projects (organization_id);
 
create table public.tickets (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null references public.organizations(id) on delete cascade,
  project_id uuid references public.projects(id) on delete set null,
  title text not null,
  body text,
  created_by uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);
 
create index tickets_org_idx on public.tickets (organization_id);
create index tickets_project_idx on public.tickets (project_id);

Why this matters:

  • Composite primary key on membership prevents duplicates.
  • Every tenant-scoped table has a direct organization_id column, even if it also references another tenant-scoped table.
  • Indexing organization_id is non-negotiable. Without it, RLS filtering becomes an expensive seq scan under load.

💡 Tip: Denormalize organization_id onto every tenant-scoped table, even when you can derive it through joins. It improves query performance and makes RLS policies simpler and less error-prone.

# Roles, Claims, and How Supabase Evaluates RLS#

Supabase uses Postgres roles and JWT claims. In practice:

  • anon and authenticated are the roles you’ll use most.
  • auth.uid() returns the current user ID from the JWT.
  • Policies are evaluated per table, per command (SELECT, INSERT, UPDATE, DELETE).
  • If RLS is enabled and there is no matching policy, access is denied.

The key design choice is: where do roles live?

Store tenant roles in a table and check it from policies. This supports:

  • Users in multiple organizations
  • Role changes without token refresh issues
  • Auditable membership history if you add logs later

Avoid relying on custom JWT claims for tenant role unless you fully control token issuance and refresh strategy. Claims drift is a real issue, and drift in access control is a security issue.

# Implementing RLS: Policies That Scale With Complexity#

Step 1: Enable RLS on Tenant Tables#

SQL
alter table public.organizations enable row level security;
alter table public.organization_members enable row level security;
alter table public.projects enable row level security;
alter table public.tickets enable row level security;

Also consider forcing it:

SQL
alter table public.organizations force row level security;
alter table public.projects force row level security;
alter table public.tickets force row level security;

Force RLS is a defensive option that prevents table owners from bypassing policies in certain contexts. Use it if you are disciplined about admin access and migrations.

Step 2: Helper Predicate via SQL Function#

A reusable helper reduces policy duplication. Keep it simple, stable, and safe.

SQL
create or replace function public.is_org_member(org_id uuid)
returns boolean
language sql
stable
as $$
  select exists (
    select 1
    from public.organization_members m
    where m.organization_id = org_id
      and m.user_id = auth.uid()
  );
$$;

You can also add role checks:

SQL
create or replace function public.org_role(org_id uuid)
returns text
language sql
stable
as $$
  select m.role
  from public.organization_members m
  where m.organization_id = org_id
    and m.user_id = auth.uid()
  limit 1;
$$;

Step 3: Organization Visibility Policy#

Organizations should be visible only if the user is a member.

SQL
create policy org_select_for_members
on public.organizations
for select
to authenticated
using (public.is_org_member(id));

Step 4: Membership Policies#

Membership tables are sensitive because they can reveal tenant structure and user lists. Decide what’s allowed.

A common rule:

  • Any member can see the membership list for their org (for UI like “Team members”).
  • Only owners and admins can add or remove members.
SQL
create policy members_select_in_org
on public.organization_members
for select
to authenticated
using (public.is_org_member(organization_id));

For inserts, restrict by role. Also lock down user_id assignments to avoid “invite myself as owner” attacks.

SQL
create policy members_insert_admin_only
on public.organization_members
for insert
to authenticated
with check (
  public.org_role(organization_id) in ('owner', 'admin')
);

For deletes:

SQL
create policy members_delete_admin_only
on public.organization_members
for delete
to authenticated
using (
  public.org_role(organization_id) in ('owner', 'admin')
);

You can refine this further, like preventing removal of the last owner. That’s best done in a transaction or a trigger, not only in RLS.

⚠️ Warning: RLS can’t reliably enforce complex cross-row invariants like “at least one owner remains” without careful locking. Implement that logic in a single SQL function or transaction and expose it via RPC.

Step 5: Tenant-Scoped Resource Policies#

For projects:

SQL
create policy projects_select_in_org
on public.projects
for select
to authenticated
using (public.is_org_member(organization_id));
 
create policy projects_insert_in_org
on public.projects
for insert
to authenticated
with check (public.is_org_member(organization_id));
 
create policy projects_update_in_org
on public.projects
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));
 
create policy projects_delete_admin_only
on public.projects
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));

For tickets, similar pattern:

SQL
create policy tickets_select_in_org
on public.tickets
for select
to authenticated
using (public.is_org_member(organization_id));
 
create policy tickets_insert_in_org
on public.tickets
for insert
to authenticated
with check (public.is_org_member(organization_id));
 
create policy tickets_update_in_org
on public.tickets
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));

Why both using and with check for updates:

  • using controls which existing rows you can target.
  • with check controls what the row can become after update.
  • Without with check, a user might update a row’s organization_id to move it into a different tenant if your schema allows it.

If organization_id should never change, enforce it:

  • Make it immutable at the app level
  • Consider a trigger that raises an exception on change
  • Or remove update permission entirely for that column via views or RPC

# Next.js App Router: Safe Server-Side Access Patterns#

Pattern 1: Server Components and Server Actions Using the User Session#

Your default should be: use the user’s JWT, call Supabase, let RLS filter.

This requires a server-side Supabase client that can read cookies and attach the session automatically. The exact implementation varies depending on your setup, but the principle is consistent.

A minimal “server client” factory might look like this:

TypeScript
// lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
 
export function createSupabaseServerClient(accessToken: string) {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { global: { headers: { Authorization: `Bearer ${accessToken}` } } }
  );
}

Then in a server action, you pass the user token from your auth layer.

TypeScript
'use server';
 
import { createSupabaseServerClient } from '@/lib/supabase/server';
 
export async function listProjects(accessToken: string, organizationId: string) {
  const supabase = createSupabaseServerClient(accessToken);
 
  const { data, error } = await supabase
    .from('projects')
    .select('id, name, created_at')
    .eq('organization_id', organizationId)
    .order('created_at', { ascending: false });
 
  if (error) throw new Error(error.message);
  return data;
}

The .eq('organization_id', organizationId) is not the security boundary. It reduces rows and improves performance. If a user passes another tenant ID, RLS still blocks access.

Pattern 2: Use RPC for Privileged Multi-Step Operations#

For actions like “invite member,” you often need:

  • Validate role
  • Insert membership
  • Maybe write an audit row
  • Maybe send an email job

Doing it across multiple round-trips from Next.js increases race conditions. Prefer a single transaction on the database.

SQL
create or replace function public.invite_member(org_id uuid, new_user_id uuid, new_role text)
returns void
language plpgsql
security invoker
as $$
begin
  if public.org_role(org_id) not in ('owner', 'admin') then
    raise exception 'not allowed';
  end if;
 
  insert into public.organization_members(organization_id, user_id, role)
  values (org_id, new_user_id, new_role)
  on conflict (organization_id, user_id) do update set role = excluded.role;
end;
$$;

Call from Next.js using the user session:

TypeScript
'use server';
 
import { createSupabaseServerClient } from '@/lib/supabase/server';
 
export async function inviteMember(accessToken: string, orgId: string, userId: string) {
  const supabase = createSupabaseServerClient(accessToken);
  const { error } = await supabase.rpc('invite_member', {
    org_id: orgId,
    new_user_id: userId,
    new_role: 'member',
  });
  if (error) throw new Error(error.message);
}

Keep it security invoker so RLS and role checks apply to the caller.

ℹ️ Note: Avoid security definer for tenant data unless you deeply understand how it can bypass RLS. If you must use it, explicitly enforce tenant checks inside the function and limit returned columns.

Pattern 3: Service Role for Background Jobs Only#

Some workloads legitimately need service role:

  • Stripe webhooks syncing subscription status
  • Scheduled jobs (daily invoices)
  • Admin dashboard that spans all tenants

In Next.js, that should run in server-only routes with strict request authentication, and never for user-scoped reads.

A safe baseline:

  • Service role used only in /api/webhooks/* or internal cron endpoints.
  • Endpoints protected with signature verification or internal auth.
  • All user-facing pages and actions use anon key plus user JWT.

# Common Pitfalls That Break Tenant Isolation#

# Pitfall 1: Using Service Role for “Convenience”#

Developers reach for service role because it “just works” when RLS blocks a query. That’s exactly why it’s dangerous.

If you use service role in any request path that is user-triggered, you have to rebuild authorization in app code perfectly. That’s hard to maintain and easy to regress.

Concrete risk pattern:

  • A list endpoint uses service role.
  • It “filters by organization_id” from a query param.
  • Attacker changes param and reads another tenant.

The fix:

  • Use user JWT for tenant-scoped reads.
  • Keep service role limited to server-to-server flows.
  • Add automated tests that assert cross-tenant reads return zero rows.

RLS is table-level. If you join a tenant-scoped table to a non-protected table, you can accidentally leak data.

Example scenario:

  • tickets has RLS.
  • You join to ticket_comments but forgot to enable RLS there.
  • A user selects tickets and gets comments from other orgs because comments table isn’t filtered.

Fix:

  1. 1
    Every table containing tenant data must have:
    • organization_id
    • RLS enabled
    • Policies enforcing membership
  2. 2
    Avoid tables that “implicitly belong” to a tenant only through joins. Make tenant ownership explicit.

If you absolutely must derive tenant via joins, enforce it with a view or RPC that applies the join constraint server-side, then lock down direct table access.

# Pitfall 3: Policies That Reference Unindexed Columns#

RLS adds predicates to queries. If your policy checks membership via a subquery, that subquery must be fast.

Ensure you have indexes:

  • organization_members (organization_id, user_id) is already a primary key in our model
  • If you query by user_id a lot, add organization_members_user_idx (user_id)

Bad performance becomes a security issue when teams “temporarily” disable RLS to ship.

# Pitfall 4: Allowing Updates That Change Tenant Ownership#

If organization_id is editable and your update policy is too permissive, attackers can try to “move” rows between tenants.

Mitigations:

  • Make organization_id not updatable via RPC or views.
  • Add a trigger to block changes.
  • Use with check in UPDATE policies.
  • Consider project_id and similar FK fields too, if moving between projects implies access changes.

# Pitfall 5: Not Testing RLS With Real Queries#

RLS bugs don’t show up in unit tests that mock data access.

What you should test:

  • User A in org A cannot read or update org B rows.
  • User A cannot insert a row for org B.
  • Admin-only operations fail for members.
  • Joins do not leak cross-tenant data.

Even a small integration test suite catches most regressions.

# Deployment Checklist for Next.js and Supabase RLS#

Use this before staging and production deploys.

AreaCheckWhy it matters
RLS coverageRLS enabled on every tenant-scoped tableOne missed table can leak data
PoliciesSELECT, INSERT, UPDATE, DELETE policies defined intentionallyMissing policy means denied or inconsistent behavior
Tenant columnEvery tenant table includes organization_id and indexesSimpler policies and predictable performance
Membership modelMembership table has PK and role constraintPrevent duplicate memberships and invalid roles
Service roleNot used in user-driven request pathsAvoid accidental full-data access
RPC functionsPrefer security invoker, validate role inside functionPrevent bypassing RLS
TriggersBlock tenant ID changes where requiredPrevent ownership reassignment attacks
LoggingLog admin actions and membership changesAuditing and incident response
EnvironmentService role key never exposed to clientTreat it like database root
TestsCross-tenant access tests in CIPrevent regressions
ObservabilityMonitor slow queries on membership checksPerformance issues push teams to unsafe shortcuts

💡 Tip: In CI, run a “tenant isolation smoke test” that creates two orgs and two users, inserts data, and asserts every cross-tenant query returns an empty set. This takes minutes and prevents expensive incidents.

# Key Takeaways#

  • Design multi-tenant tables with an explicit organization_id on every tenant-scoped row, backed by indexes for predictable RLS performance.
  • Store roles in organization_members and check membership in RLS policies using helpers like public.is_org_member(org_id).
  • In Next.js App Router, use the user JWT for all tenant-scoped reads and writes so RLS is always the security boundary.
  • Use RPC for multi-step operations and keep functions security invoker unless you have a strong reason and extra safeguards.
  • Avoid service role in user-triggered code paths and audit your schema for leaky joins by enabling RLS everywhere tenant data exists.
  • Ship with a deployment checklist and CI tests that explicitly validate cross-tenant access is impossible.

# Conclusion#

Next.js and Supabase can be a strong stack for multi-tenant SaaS, but only if you treat the database as the enforcement layer. When your tables are designed for tenancy, your RLS policies are consistent, and your Next.js server code uses user sessions instead of service role shortcuts, you get isolation that’s hard to accidentally break.

If you want us to review your schema and policies, or implement a secure multi-tenant foundation with Next.js App Router and Supabase, contact Samioda and we’ll help you ship a production-ready architecture faster.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.