Skip to content
All articles

Multi-tenant SaaS isolation with Postgres RLS and workspace auth

Combine live server-side workspace membership guards with indexed Postgres Row-Level Security policies so tenant data stays isolated across web, mobile, and background jobs.

supastack team2 min read

Should I use Postgres Row-Level Security or application-level workspace checks?

It depends on how your application connects to Postgres. If browser or mobile clients query the database directly through PostgREST, Row-Level Security (RLS) is mandatory. If all traffic flows through a server-side TypeScript boundary, explicit membership checks on every query are your primary defense, and RLS serves as defense-in-depth against forgotten WHERE clauses. Combine live session-to-membership resolution with indexed workspace_id policies. supastack enforces server-side workspace guards across web, mobile, and desktop, and provides a free RLS policy generator for Supabase tables.

Application guards and database policies solve different failures

Developers often treat application-level authorization and Postgres Row-Level Security as mutually exclusive. In practice, they catch different bugs: server-side guards enforce role hierarchy, entitlement checks, and audit logging before business logic runs, while RLS guarantees that a missing WHERE workspace_id = ... clause cannot dump every tenant's rows.

Whether you run Supabase or self-hosted Postgres with Drizzle, every tenant-owned table should carry a non-null workspace_id foreign key. The workspace boundaries article explains why that scope must always be derived on the server rather than read from a request parameter.

  • Application guards validate session freshness, member roles (owner, admin, member), and feature entitlements.
  • Database RLS policies act as a hard floor when direct client queries or raw SQL helpers are used.
  • Both layers depend on a single source of truth: the workspace_members join table.

Resolve live membership instead of trusting stale cookies

Storing a currentWorkspaceId in a browser cookie or client header is convenient for navigation, but a cookie is a preference, not a capability. If an admin removes a teammate from a workspace, that user's browser still holds the old workspace cookie.

Join the active session against workspace_members on every request. When the membership row is gone, reject the request immediately instead of waiting for a JWT or session cookie to expire. The authentication architecture post traces how identity flows into this check.

export async function requireWorkspaceViewer(requestedWorkspaceId?: string) {
  const session = await requireAuthenticatedSession();
  const membership = await db.query.workspaceMembers.findFirst({
    where: and(
      eq(workspaceMembers.userId, session.user.id),
      requestedWorkspaceId
        ? eq(workspaceMembers.workspaceId, requestedWorkspaceId)
        : undefined,
    ),
    with: { workspace: true },
  });

  if (!membership) throw new ForbiddenError("Not a member of this workspace");
  return { user: session.user, workspace: membership.workspace, role: membership.role };
}

Write fast, indexed Row-Level Security policies

Naive RLS policies that call auth.uid() directly on every row force Postgres to re-evaluate the function per tuple. Wrapping (select auth.uid()) in a scalar subquery lets the query planner cache the value as an initPlan, and a composite index on (workspace_id, user_id) keeps membership lookups in sub-millisecond index scans.

You can generate ready-to-run SELECT, INSERT, UPDATE, and DELETE policies for multi-tenant tables using the free Supabase RLS Policy Generator without writing boilerplate SQL by hand.

CREATE INDEX IF NOT EXISTS idx_workspace_members_lookup
  ON workspace_members (workspace_id, user_id);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY "workspace_members_manage_documents"
ON documents FOR ALL TO authenticated
USING (
  workspace_id IN (
    SELECT wm.workspace_id FROM workspace_members wm
    WHERE wm.user_id = (SELECT auth.uid())
  )
)
WITH CHECK (
  workspace_id IN (
    SELECT wm.workspace_id FROM workspace_members wm
    WHERE wm.user_id = (SELECT auth.uid())
      AND wm.role IN ('owner', 'admin', 'member')
  )
);

Common questions about RLS and workspace auth

Teams wiring multi-tenant Postgres hit the same edge cases around service roles, background jobs, and performance.

  • Does Drizzle ORM or Prisma bypass RLS? — Yes, if your server connects with the postgres superuser or Supabase service_role key. That is why server actions and API routes must call requireWorkspaceViewer() and scope every query by workspaceId.
  • Why wrap (select auth.uid()) in parentheses inside RLS policies? — It tells the Postgres optimizer to evaluate auth.uid() once per statement instead of once per scanned row, avoiding sequential-scan slowdowns on large tables.
  • How should background jobs access tenant data? — Pass the verified workspaceId in the job payload from the authenticated server route, and scope worker reads and writes to that workspaceId.
  • How do I verify that tenant isolation actually holds? — Write automated negative tests where User A requests or mutates User B's record ID across web, mobile, and API routes, and assert 403 / 404 with zero rows changed.

Frequently asked questions

14 questions

What is supastack?

supastack is a modular foundation for building AI-powered SaaS products. It brings together common infrastructure so you can focus on product-specific features.

Does it support web and mobile?

Yes. supastack includes Next.js and Expo apps as starting points for web and mobile experiences.

What authentication is included?

Better Auth covers email OTP, magic-link, and social sign-in; email/password stays available at the API layer and through the Auth.js credentials engine for products that need it. Personal workspaces are provisioned with the account so product records stay scoped from day one.

How does it support AI?

supastack includes configurable multi-provider AI, usage tracking, budgets, and encrypted bring-your-own-key support.

How are subscriptions handled?

For products built on the kit, Stripe billing, subscriptions, and entitlements form the paid-access foundation. Provider secrets stay server-only.

Where does my product-specific code live?

Product-specific features remain in a separate codebase while the product configures and integrates supastack’s foundation modules.

How does supastack pricing work?

The kit is a one-time purchase with lifetime updates: Single Stack at $249 — your pick of Next.js (Web), Expo (iOS & Android), or Tauri (macOS & Windows Desktop) — Dual Stack at $398 with any two repos, or the Full Bundle at $399 with all three repos and the full monorepo, plus $75 per extra developer seat. No kit subscription. Checkout on this site delivers your license key by email, with repository access via GitHub invite.

How do workspaces scope my data?

User-owned records are scoped by workspaceId. Identity, entitlements, AI usage, billing events, and audit entries stay inside the personal workspace boundary.

How do background jobs work?

Trigger.dev is the explicit job runtime. The JobRunner contract only promises enqueue, status, and cancel—workflow semantics stay Trigger.dev-specific and explicit.

How is email different from outreach?

Transactional email covers product workflows such as completion notices. Cold outreach and campaigns are product concerns outside this kit’s email module.

How do mobile purchases work?

Mobile includes native purchases and restore through RevenueCat, with access verified by your server. Configure the store accounts and complete device testing before release. Web billing remains separate.

Where do AI provider keys live?

Bring-your-own-key credentials are encrypted and stay server-only. Keys are redacted from logs; usage and budgets remain visible at the workspace level.

Which payment methods does checkout accept?

The Stripe-hosted checkout accepts cards and can also offer Alipay and WeChat Pay once those wallets are enabled on the Stripe account — handy for buyers in China. Wallet authorizations confirm asynchronously, so the license page updates as soon as the payment clears.

Can I get a refund?

After you've got access to the repository, supastack is yours forever, so it cannot be refunded. But rest assured, you get the complete source code with lifetime updates to build and ship unlimited products for yourself or clients. You just cannot resell the code or redistribute the repository as a starter kit or template. One exception is the launch cashback — launch a live product built with supastack, share it, and reach 5,000 organic views or 50 organic likes within 60 days to get 50% of what you paid back; see the terms.

EN