---
id: supabase-rls-wire
name: supabase-rls-wire
description: Generate Postgres tables + Row-Level Security policies for multi-tenant SaaS — users, subscriptions, organizations, and self-select / org-member policies tied to Clerk JWT.
version: 1.0.0
source: bundled
tags: [database, supabase, postgres, rls, multi-tenant, saas]
---

# Skill: supabase-rls-wire

Use this skill when the user wants to **wire Supabase RLS policies** for
a multi-tenant SaaS. Generates the `users` + `subscriptions` foundation
plus an optional `organizations` + `members` layer for team-scoped data.

## Pre-flight checks

1. Verify `supabase` CLI is installed (`supabase --version`)
2. Confirm there's a `supabase/migrations/` directory; if not, run `supabase init`
3. Check `lib/db.ts` exposes a service-role Supabase client (write paths need it)
4. Verify Clerk JWT is configured as the Supabase JWT issuer:
   https://clerk.com/docs/integrations/databases/supabase

## Output: `supabase/migrations/<timestamp>_rls_foundation.sql`

```sql
-- ─── users ──────────────────────────────────────────────────────────────
create table if not exists public.users (
  clerk_id            text primary key,
  email               text unique,
  first_name          text,
  last_name           text,
  stripe_customer_id  text unique,
  created_at          timestamptz not null default now(),
  updated_at          timestamptz not null default now()
);

create index if not exists users_stripe_customer_id_idx
  on public.users (stripe_customer_id);

alter table public.users enable row level security;

-- Users can SELECT their own row only.
create policy "users self-select" on public.users
  for select using (clerk_id = auth.jwt() ->> 'sub');

-- Inserts/updates happen via service role (webhooks, server actions).
-- No public insert/update/delete policy.

-- ─── subscriptions ──────────────────────────────────────────────────────
create table if not exists public.subscriptions (
  stripe_subscription_id  text primary key,
  stripe_customer_id      text not null references public.users(stripe_customer_id) on delete cascade,
  status                  text not null,
  price_id                text,
  current_period_end      timestamptz,
  canceled_at             timestamptz,
  created_at              timestamptz not null default now()
);

alter table public.subscriptions enable row level security;

create policy "subscriptions self-select" on public.subscriptions
  for select using (
    stripe_customer_id in (
      select stripe_customer_id from public.users where clerk_id = auth.jwt() ->> 'sub'
    )
  );
```

## Output (optional): `supabase/migrations/<timestamp>_orgs.sql`

When the user wants multi-tenant via **organizations + members**:

```sql
-- ─── organizations ──────────────────────────────────────────────────────
create table if not exists public.organizations (
  id          uuid primary key default gen_random_uuid(),
  slug        text unique not null,
  name        text not null,
  owner_id    text not null references public.users(clerk_id) on delete cascade,
  created_at  timestamptz not null default now()
);

alter table public.organizations enable row level security;

-- ─── org_members ────────────────────────────────────────────────────────
create table if not exists public.org_members (
  org_id     uuid not null references public.organizations(id) on delete cascade,
  user_id    text not null references public.users(clerk_id) on delete cascade,
  role       text not null check (role in ('owner', 'admin', 'member')),
  joined_at  timestamptz not null default now(),
  primary key (org_id, user_id)
);

alter table public.org_members enable row level security;

-- Org-scoped read policy on org_members (self-membership check).
create policy "members self-select" on public.org_members
  for select using (
    user_id = auth.jwt() ->> 'sub'
    OR org_id in (select org_id from public.org_members where user_id = auth.jwt() ->> 'sub')
  );

-- Organizations: visible only to members.
create policy "orgs visible to members" on public.organizations
  for select using (
    id in (select org_id from public.org_members where user_id = auth.jwt() ->> 'sub')
  );

-- Helper view (often useful in app code).
create or replace view public.my_orgs as
  select o.* from public.organizations o
  join public.org_members m on m.org_id = o.id
  where m.user_id = auth.jwt() ->> 'sub';
```

## Output (optional): tenant-scoped data tables

For any table that holds **organization-scoped data** (projects, posts,
etc.), apply this RLS template:

```sql
create table if not exists public.<entity>s (
  id          uuid primary key default gen_random_uuid(),
  org_id      uuid not null references public.organizations(id) on delete cascade,
  -- ... your domain columns
  created_by  text references public.users(clerk_id),
  created_at  timestamptz not null default now()
);

alter table public.<entity>s enable row level security;

create policy "<entity>s org-scoped read" on public.<entity>s
  for select using (
    org_id in (select org_id from public.org_members where user_id = auth.jwt() ->> 'sub')
  );

create policy "<entity>s org-scoped write" on public.<entity>s
  for insert with check (
    org_id in (select org_id from public.org_members where user_id = auth.jwt() ->> 'sub')
  );

create policy "<entity>s org-scoped update" on public.<entity>s
  for update using (
    org_id in (select org_id from public.org_members where user_id = auth.jwt() ->> 'sub')
  );
```

## Post-install steps

1. Apply migrations: `supabase db push`
2. Configure Clerk → Supabase JWT integration (Clerk Dashboard → JWT
   Templates → "supabase" template):
   ```json
   { "role": "authenticated", "sub": "{{user.id}}" }
   ```
3. In Next.js, pass the Clerk JWT to Supabase on each request:
   ```ts
   const { getToken } = auth();
   const token = await getToken({ template: 'supabase' });
   const supabase = createClient(url, anon, { global: { headers: { Authorization: `Bearer ${token}` } } });
   ```
4. Test RLS by signing in as User A, then trying to fetch User B's data
   → should return zero rows

## Composition

- Required by `clerk-wire` (user sync target) and `stripe-billing-wire`
  (subscriptions table)
- Composes with any domain-table skill following the org-scoped template

## Anti-patterns to avoid

- ❌ `using (true)` — disables RLS, defeats the point
- ❌ Using the **anon** key from server-side actions when you need to
  bypass RLS (use the **service-role** key in `lib/db.ts:getServiceSupabase`)
- ❌ Storing PII directly in `org_members` — keep PII in `users`, join when needed
