Every SaaS with more than one customer has the same failure mode. Somewhere there is a query that should say "only this team's rows" and doesn't. Nothing crashes. A customer simply sees another customer's invoices, and you find out from them.
When an agent writes much of the code, the risk does not go away. An agent can write a hundred correct queries and then forget the filter on the hundred-and-first. So shipanysaas does not rely on every query being right. It puts the rule in Postgres.
The rule lives in the database
Everything in the kit belongs to an account: a personal account (one per user) or a team account (shared by its members). Tables carry an account_id, and row-level security policies decide which rows the signed-in user can see.
A policy for a team table reads like this:
create policy "projects_read" on public.projects for select
to authenticated using (
account_id = (select auth.uid())
or public.has_role_on_account(account_id)
);
With that in place, select * from projects with no filter returns only rows from the user's personal account and the teams they belong to. The forgotten where clause becomes harmless instead of a leak.
What is locked down
- Row-level security is on for all 13 tables in
public. That covers accounts, memberships, roles, invitations, billing, notifications and one-time tokens. - Grants start from nothing. Each table revokes everything from the API roles and grants back only what is needed. A migration also strips the
TRUNCATE,REFERENCESandTRIGGERprivileges Supabase hands out by default.TRUNCATEmatters because it ignores row-level security entirely. - Rows cannot be moved between accounts. Where users may update a table, the grant lists only the editable columns.
account_idis never one of them, so a user cannot "update" a row into someone else's account. - Nobody can give themselves a paid plan. Users can read their subscriptions and orders but not write them. Only the payment webhook writes them, after checking the provider's signature.
- Two-step sign-in is enforced by the database too. For a user who has set up a second factor, restrictive policies hide account data until the session has passed it.
- The admin panel needs two-step sign-in.
is_super_admin()is false unless the session passed the second step, and the role comes from server-setapp_metadata, which users cannot edit.
How it is tested
Claims like these are only worth something if a test breaks when they stop being true. The kit has 23 pgTAP test files in apps/web/supabase/tests/database, and you run them with:
pnpm supabase:web:test
Thirteen of those files sign in as one user and try to read or change another account's data: memberships, invitations, notifications, files, billing and more. Each attempt must fail.
Two tests guard the setup itself. schema-conditions.test.sql fails if any table in public has row-level security off, so a new table you forget to lock fails the suite. privileges.test.sql fails if the default TRUNCATE privilege comes back.
Where the limits are
Being clear about what this does not do matters as much as the list above.
- The admin client skips row-level security. Server code that uses the secret key (webhooks, invitations, the admin panel) has to check permissions itself. Use it only when the normal client cannot do the job.
- Profile pictures and team logos are public by URL. The
account_imagebucket is public, which suits avatars. Private files need their own private bucket and policies. - The checks cover the
publicschema. Tables you put elsewhere are not checked byschema-conditions.test.sql. - Your new tables are your job. The kit gives you the helpers, a table template in
apps/web/supabase/AGENTS.md, and a review skill. It cannot write the policy for a table it has never seen. - This is not a certification. The kit has no SOC 2 or HIPAA certification. What it has is tests you can read and run.
Adding a table safely
- Write the table in a new file in
apps/web/supabase/schemas/withaccount_id, row-level security on,revoke all, and column-level update grants. - Write policies with
has_role_on_accountandhas_permission. - Create the migration and regenerate types.
- Add a pgTAP test that signs in as an outsider and tries to read, insert, update and delete.
- Run
pnpm supabase:web:test.
If a coding agent makes the change, the root AGENTS.md requires the /rls-review skill afterwards. It audits the change for tenant-isolation holes and writes attack tests like the ones above.
The full reference is in the docs: Row-level security and Database tests.
