Skip to content
Back to Blog
  • multi-tenant
  • Supabase
  • RLS
  • Next.js
  • data security
  • architecture

Mastering Data Isolation in Next.js Multi-Tenant Apps with Supabase RLS

Developers building multi-tenant Next.js applications with Supabase often struggle with guaranteeing strict data isolation.

October 30, 20269 min read
Mastering Data Isolation in Next.js Multi-Tenant Apps with Supabase RLS

Developers building multi-tenant Next.js applications with Supabase inevitably confront a critical challenge: ensuring absolute data isolation between tenants. The problem isn't just about preventing malicious access; it's about eliminating the risk of accidental data exposure, maintaining data integrity, and simplifying your application logic. Technical founders and CTOs using Supabase for their multi-tenant backend quickly realize that relying solely on application-level filtering (e.g., WHERE tenant_id = '...') introduces significant production edge cases, from forgotten clauses to race conditions during data manipulation, making their systems vulnerable and complex to maintain.

This guide will walk you through implementing production-grade Row-Level Security (RLS) with Supabase in a Next.js App Router environment, fortifying your multi-tenant architecture against these common pitfalls.

TL;DR

  • Supabase RLS is the definitive solution for enforcing data isolation in multi-tenant applications.
  • Implement RLS policies directly at the database level for robust, unbypassable security.
  • Leverage auth.uid() and a user_tenants mapping table to identify the current tenant securely.
  • Integrate RLS seamlessly with Next.js App Router API routes using createRouteHandlerClient.
  • Understand and mitigate production edge cases like the service_role key and policy bypass scenarios.

The Peril of Application-Level Filtering

Many developers initially approach multi-tenant data isolation by adding a tenant_id column to every relevant table and filtering queries in their application code: SELECT * FROM documents WHERE tenant_id = currentTenantId;. While seemingly straightforward, this approach is a ticking time bomb in a production environment.

Security Vulnerabilities: A single forgotten WHERE clause in a new feature, an overlooked .update() call, or an error in a complex join could inadvertently expose one tenant's data to another. This isn't just a theoretical risk; it's a common source of critical data breaches in multi-tenant systems. The security of your entire application hinges on the vigilance of every developer, every query, every time.

Maintenance Burden and Code Duplication: As your application grows, you'll find yourself repeating the tenant_id filter across countless queries, API routes, and database interactions. This leads to significant code duplication, making your codebase harder to read, test, and maintain. Any change to your tenant identification logic requires a sweeping review and modification of your entire data access layer.

Race Conditions and Data Integrity: In scenarios involving concurrent writes or complex transactions, relying on application-level filtering can introduce race conditions. If a user's tenant_id is somehow mismatched or updated mid-transaction, data could be written to the wrong tenant or become inaccessible. RLS, by operating at the database transaction level, inherently mitigates many of these risks.

Supabase RLS: The Database-Native Solution

Supabase Row-Level Security (RLS) shifts the responsibility of data isolation from your application code to the database itself. When RLS is enabled on a table, every query executed by an authenticated user is automatically filtered by the policies you define, before the data even leaves the database. This provides an unbypassable layer of security that your application code cannot accidentally circumvent.

What RLS Is: RLS allows you to define policies that restrict which rows users can access or modify based on their identity or other criteria. These policies are enforced at the PostgreSQL database level, meaning that even if an attacker manages to execute raw SQL queries, they will still be bound by your RLS rules.

Enabling RLS: The first step for any table requiring tenant isolation is to enable RLS:

ALTER TABLE your_table_name ENABLE ROW LEVEL SECURITY;

Once enabled, no user (other than the postgres role or those using the service_role key) can access any rows until you define at least one policy.

Key Concepts: USING and WITH CHECK Clauses:

  • USING clause: Determines which existing rows a user can see (for SELECT, UPDATE, DELETE operations). If the USING condition evaluates to TRUE for a row, the user can interact with it.
  • WITH CHECK clause: Determines which new rows a user can create or which updated rows they can modify (for INSERT and UPDATE operations). If the WITH CHECK condition evaluates to TRUE for the row being inserted or updated, the operation proceeds.

For simple tenant isolation, USING and WITH CHECK often have identical conditions, ensuring that users can only interact with data belonging to their assigned tenant.

Identifying the Tenant: auth.uid() and Custom Claims

Supabase leverages PostgreSQL's auth schema, which exposes functions like auth.uid() to retrieve the id of the currently authenticated user. This is the cornerstone of RLS in a multi-tenant setup.

Your database schema needs a way to map an authenticated user to the tenants they belong to. A common pattern is a user_tenants (or similar) junction table:

CREATE TABLE tenants (
    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    name text NOT NULL
);

CREATE TABLE user_tenants (
    user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
    tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
    PRIMARY KEY (user_id, tenant_id)
);

-- Example data table that needs tenant isolation
CREATE TABLE documents (
    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
    title text NOT NULL,
    content text
);

With this structure, auth.uid() allows your RLS policies to check if the current user is associated with the tenant_id of the row they are trying to access.

While Supabase allows for custom JWT claims, for most multi-tenant scenarios, mapping auth.uid() to a user_tenants table is more secure and flexible than embedding tenant_id directly into the JWT, especially if a user can belong to multiple tenants or their tenant access changes frequently.

Crafting Production-Grade RLS Policies

Let's create a robust RLS policy for our documents table. This policy will ensure that a user can only SELECT, INSERT, UPDATE, or DELETE documents that belong to a tenant they are associated with.

-- 1. Enable RLS on the documents table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- 2. Create a policy for all operations (SELECT, INSERT, UPDATE, DELETE)
--    This policy ensures users can only access documents belonging to tenants
--    they are explicitly linked to via the user_tenants table.
CREATE POLICY tenant_isolation_policy ON documents
FOR ALL
USING (
    EXISTS (
        SELECT 1
        FROM user_tenants ut
        WHERE ut.user_id = auth.uid()
        AND ut.tenant_id = documents.tenant_id
    )
)
WITH CHECK (
    EXISTS (
        SELECT 1
        FROM user_tenants ut
        WHERE ut.user_id = auth.uid()
        AND ut.tenant_id = documents.tenant_id
    )
);

-- Optional: If you want to ensure only authenticated users can access anything
-- CREATE POLICY require_auth ON documents FOR SELECT USING (auth.uid() IS NOT NULL);

Explanation:

  • FOR ALL: This applies the policy to SELECT, INSERT, UPDATE, and DELETE operations. You can create separate policies for each operation type if you need more granular control (e.g., a user can read all documents in their tenant but only update their own).
  • USING (EXISTS (...)): This is the core of the policy. It checks if there exists at least one entry in the user_tenants table where the user_id matches the currently authenticated user (auth.uid()) and the tenant_id matches the tenant_id of the documents row being accessed. If this condition is true, the user has access.
  • WITH CHECK (EXISTS (...)): For INSERT and UPDATE, this ensures that any new or modified document will also conform to the policy. A user cannot insert a document for a tenant_id they are not associated with.

Integrating with Next.js API Routes

With RLS policies in place, your Next.js API routes become remarkably simpler and more secure. You no longer need to manually add WHERE tenant_id = currentTenantId clauses. Supabase's client libraries, when initialized with the user's session, automatically apply the RLS policies.

For Next.js App Router, you'll typically use createRouteHandlerClient to get a Supabase client that is aware of the user's session (via cookies).

// app/api/documents/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET() {
  const supabase = createRouteHandlerClient({ cookies });

  // Ensure the user is authenticated
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // RLS will automatically filter documents based on the authenticated user's tenant access.
  // No explicit tenant_id filter is needed here in the application code!
  const { data: documents, error } = await supabase
    .from('documents')
    .select('*');

  if (error) {
    console.error('Error fetching documents:', error);
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  return NextResponse.json(documents);
}

export async function POST(request: Request) {
  const supabase = createRouteHandlerClient({ cookies });
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { title, content, tenant_id } = await request.json();

  // When inserting, RLS's WITH CHECK clause will ensure the user is allowed
  // to insert for the provided tenant_id. If not, the insert will fail silently
  // or with a permissions error, depending on Supabase client error handling.
  const { data, error } = await supabase
    .from('documents')
    .insert({ title, content, tenant_id });

  if (error) {
    console.error('Error creating document:', error);
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  return NextResponse.json(data, { status: 201 });
}

Notice the absence of explicit WHERE tenant_id = ... clauses in the supabase.from('documents').select('*') and .insert() calls. This is the power of RLS: your application code focuses on what data to retrieve or store, and the database handles the who and where of access control.

Production Edge Cases and Best Practices

While RLS is powerful, understanding its nuances and potential edge cases is crucial for production deployments.

Bypassing RLS: The service_role Key

Supabase provides a service_role key (often referred to as the anon key but with elevated privileges) that can bypass all RLS policies. This key is incredibly powerful and should never be exposed client-side or used in standard API routes where user context is required. It's designed for backend, administrative tasks like:

  • Running database migrations.
  • Seeding initial data for new tenants.
  • Backend cron jobs that need to access data across all tenants (e.g., analytics processing).
  • Creating new users or managing tenant relationships in an admin panel.

When using the service_role key, ensure it's stored securely (e.g., in environment variables) and only used in trusted server-side contexts where you explicitly need to operate outside RLS constraints. For example, a Next.js API route that creates a new tenant and its initial data might use the service_role key, but it must have its own robust authentication and authorization checks.

Initial Data Seeding/Migrations

When setting up new tenants or performing schema migrations, you might need to insert data into RLS-enabled tables before a user context is available or without being restricted by RLS. In these scenarios, use the service_role key or ensure your migration scripts run with a database user that has appropriate bypass privileges.

Super-Admins and Global Data Access

Occasionally, you might have

Last updated October 30, 2026

Get thoughtful updates

Join our monthly digest for founders and builders.

Work withPolarSoftBD

Need help shipping your next product? Let's talk.