Compose your own

Builder

Toggle modules, pick a design, preview live, submit for delivery.

CRMon
Bookingon
Invoicingon
Reviewsoff
Loyaltyoff
Open builder
A la carte

Modules

18 individual operational tools at $29-$49 each. Mix and match.

See all
Visual identity

Templates

11 aesthetics. Same kit, different look. Compose orthogonally.

See all
Free forever · MIT

The three free cores

Each is a Vercel-deployable Next.js project.Browse all cores →

The Multi-Tenant RLS Security Checklist

The org-scoped Row Level Security pattern that keeps one tenant from reading another tenant, plus the four failure modes that quietly leak data across the wall.

11 min readFree, one email to unlock the full guide + download

Multi-tenant is the one bug class that does not show up in your own testing. Every screen looks right because you only ever log in as yourself. The failure is invisible until the day a second tenant reads the first tenant’s rows, and by then it is an incident, not a ticket.

This checklist is the exact Row Level Security pattern LaunchKits ships in every module. The code samples below are the real shape from the shipped SQL, not a textbook abstraction. Work through the five items and the four traps and you have closed the gap that most Supabase boilerplates leave open.

Item 1: RLS is enabled on every tenant table

This is the whole game and it is one line per table. A Postgres table exposed through PostgREST with RLS not enabled is readable and writable by anyone who has your anon key, which is public by design and sits in your client bundle. No policy is not a strict policy. It is an open door.

ALTER TABLE launchkits_businesses ENABLE ROW LEVEL SECURITY;
ALTER TABLE launchkits_business_members ENABLE ROW LEVEL SECURITY;
-- ... one line for every table that holds tenant data
Trap: The silent new-table leak

You add a feature, create a table, write the app code, ship. You never wrote a policy because the app worked. The table is wide open through the API. The fix is a rule: no table reaches main until it has ENABLE ROW LEVEL SECURITY and at least one policy. Grep your migrations for CREATE TABLE and confirm each has a matching ALTER TABLE ... ENABLE ROW LEVEL SECURITY.

The preview stops here. The full checklist below is the part that catches the leaks you cannot see: the org-membership policy shape, the recursion trap that forces a SECURITY DEFINER helper, the two flavors of USING (true), the token-scoped anon pattern, and the atomic-counter race. Drop your email to unlock the rest and download the checklist as Markdown.

Item 2: Reads are scoped to org membership, not the row owner

The naive multi-tenant policy is auth.uid() = owner_id. It is wrong the moment a business has a second team member, because only the owner can see the data. Real tenancy is shared: everyone who belongs to the business sees the business. So the policy scopes to the set of businesses the caller is a member of.

-- The membership-scoped read, used on every tenant table:
CREATE POLICY "Members read business" ON launchkits_businesses
  FOR SELECT
  USING (id IN (SELECT launchkits_user_business_ids()));

-- On a child table it keys off the tenant foreign key:
CREATE POLICY "Members read appointments" ON launchkits_booking_appointments
  FOR SELECT
  USING (business_id IN (SELECT launchkits_user_business_ids()));

Writes get the same predicate on WITH CHECK so a member cannot insert a row into a business they do not belong to. FOR ALL policies need both USING (which rows you can see and update) and WITH CHECK (which rows you are allowed to write).

CREATE POLICY "Auth manage appointments" ON launchkits_booking_appointments
  FOR ALL TO authenticated
  USING (business_id IN (SELECT launchkits_user_business_ids()))
  WITH CHECK (business_id IN (SELECT launchkits_user_business_ids()));
Trap: USING without WITH CHECK on a write policy

A FOR ALL or FOR INSERT policy with only USING and no WITH CHECK lets a user write rows they would not be allowed to read back. The row escapes the tenant boundary on the way in. Any policy that permits INSERT or UPDATE needs WITH CHECK with the same tenant predicate.

Item 3: The membership lookup runs through a SECURITY DEFINER helper

Here is the trap that makes people give up and disable RLS. Your policy on the businesses table needs to check membership. Membership lives in a members table. That members table also has RLS, and its policy references membership. Now a read of businesses triggers a read of members which triggers a read of members, and Postgres throws infinite recursion detected in policy.

The fix is to resolve membership inside a function that runs with the definer’s rights, so it reads the members table without re-triggering the caller’s policy. Mark it STABLE so the planner can cache it within a statement.

CREATE OR REPLACE FUNCTION launchkits_user_business_ids()
RETURNS SETOF UUID AS $$
  SELECT business_id
  FROM launchkits_business_members
  WHERE user_id = auth.uid()
$$ LANGUAGE sql STABLE SECURITY DEFINER;
  • SECURITY DEFINER: the function reads members with the owner’s privileges, side-stepping the recursion.
  • STABLE: signals the result does not change within a single statement, so the planner evaluates it once instead of per row.
  • auth.uid() inside: the caller identity still drives the result, so the function is not a bypass, it is a scoped, cached membership resolver.
  • Index it: CREATE INDEX ON launchkits_business_members (user_id) keeps the lookup O(log n).
Note: SECURITY DEFINER is a scalpel, not a bypass

The function is narrow on purpose. It returns only the business ids for the current auth.uid() and nothing else. Never write a broad SECURITY DEFINER helper that returns rows the caller should not see, because that function runs as the definer and RLS does not apply inside it.

Item 4: Every USING (true) is a promise you keep in the app layer

A public-facing product often needs unauthenticated reads. A booking page shows open slots to visitors who are not logged in. The tempting policy is the widest one:

-- The trap: anon can now read EVERY row in the table,
-- including customer_name, customer_email, customer_phone.
CREATE POLICY "Anon read appointments" ON launchkits_booking_appointments
  FOR SELECT TO anon USING (true);

USING (true) for the anon role does exactly what it says: it returns every row to anyone, across every tenant. If that table carries any PII, you have built a public export endpoint by accident. There are two honest ways out.

1Scope the anon read to a safe flag and safe columns

For a table that is genuinely public (a booking page’s services and settings), scope the anon read to a visibility flag instead of true. This is the token-scoped anon pattern: the row opts in to being public.

2For anything with PII, do not expose the table to anon at all

Serve derived data through a SECURITY DEFINER RPC that returns only what the public needs. For availability, return busy time ranges, never raw appointment rows. The customer’s name and phone never cross the wire.

-- The scoped-anon pattern: the row opts in via a flag, not USING (true).
CREATE POLICY "Anon read booking settings" ON launchkits_booking_settings
  FOR SELECT TO anon USING (booking_enabled = true);

CREATE POLICY "Anon read booking services" ON launchkits_booking_services
  FOR SELECT TO anon USING (active = true);
Trap: The policy name that lies

A policy named "Anyone read own invitation" with the predicate USING (true) does not read your own invitation. It reads everyone’s. The name documents intent; the predicate is the law. When the predicate is USING (true), the only thing standing between a scanner and every row is the entropy of your lookup token and the discipline of your query code, which must always filter by an exact, unguessable token. If you rely on that, make the token long and random, and never expose a list endpoint over that table.

Fix: Audit move

Grep your migrations for USING (true) and USING(true). For each hit, write one sentence naming who is allowed to read those rows and why it is safe. If you cannot write the sentence, the policy is too wide.

Item 5: Counter writes are atomic and the service key stays server-only

Usage metering, seat counts, credit balances: any shared counter that two requests can touch at once is a race waiting to happen. The trap is a read-modify-write in application code.

// TOCTOU trap: two concurrent requests both read 100, both write 110,
// and you have lost an increment. Under load this silently undercounts.
const { data } = await supabase.from('businesses').select('tokens_used')
await supabase.from('businesses')
  .update({ tokens_used: data.tokens_used + amount })

The database can do the add atomically. Push the increment into a single UPDATE, or wrap it in an RPC so the read and the write are one statement that Postgres serializes for you.

CREATE OR REPLACE FUNCTION launchkits_increment_business_usage(
  p_business_id UUID, p_user_id UUID, p_tokens INTEGER)
RETURNS void AS $$
BEGIN
  UPDATE launchkits_businesses
  SET tokens_used = tokens_used + p_tokens   -- atomic, no read-modify-write
  WHERE id = p_business_id;

  INSERT INTO launchkits_usage_log (business_id, user_id, tokens)
  VALUES (p_business_id, p_user_id, p_tokens);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Trap: The service_role key in the browser

The service_role key bypasses RLS completely. That is its job: trusted server code enforces tenancy in application logic. If it ever lands in a NEXT_PUBLIC_ variable, a client component, or a committed .env that ships to the browser bundle, every policy on this page is void for whoever finds it. Keep it in server-only env, never prefixed NEXT_PUBLIC_, and grep your bundle for it before you ship.

The one-screen checklist

  1. 1Every tenant table has ENABLE ROW LEVEL SECURITY and at least one policy. No table ships without it.
  2. 2Reads scope to org membership (business_id IN (SELECT membership_fn())), not auth.uid() = owner.
  3. 3Every INSERT/UPDATE policy has a WITH CHECK with the same tenant predicate.
  4. 4Membership is resolved through a STABLE SECURITY DEFINER function, and that function is narrow.
  5. 5The membership table has an index on user_id.
  6. 6Every USING (true) has a written, one-sentence justification. PII tables never use it for anon.
  7. 7Public reads are scoped to a visibility flag, or served through a SECURITY DEFINER RPC that returns only safe columns.
  8. 8Shared counters are incremented in a single atomic UPDATE or RPC, never read-modify-write.
  9. 9The service_role key is server-only, never NEXT_PUBLIC_, and not in the client bundle.
  10. 10You have logged in as a second tenant and confirmed you cannot see the first tenant’s data.

Item 10 is the one people skip and the one that catches everything. Create a second business, add a second user, and try to read the first business’s rows through the API with the second user’s token. If the wall holds under that test, it holds.

Free · one email

Read the rest and take it with you

The full guide plus a downloadable Markdown copy. One email, no wall on the preview above, unsubscribe any time. You also get the occasional build note when we ship something worth your inbox.

Why LaunchKits wrote this

This is the standard our own kits ship against

The patterns in this guide are not theory. They are the org-scoped RLS, atomic counters, and webhook idempotency that ship in every LaunchKits module by default. If you would rather own that foundation than rebuild it, start with the free open-source cores.