shipanysaas
Documentation
Back
  • Getting started
    • Install and run
    • Project structure
    • Configuration
    • Commands
  • Coding agents
    • Agent skills
  • Authentication
    • Email sign-in
    • OAuth providers
    • Two-step sign-in and passkeys
  • Database
    • Migrations
    • Row-level security
    • Database tests
    • Reading and writing data
  • Features
    • Teams and invitations
    • Email
    • File uploads
    • Blog, docs and changelog
  • Billing
    • Stripe and Lemon Squeezy
    • Pricing plans
    • Webhooks
  • Live demo
  • Row-level security

    How the database keeps each account's data to itself: policies on every table, narrow grants, helper functions, and the limits you should know.

    Row-level security (RLS) is a Postgres feature: each table has policies that decide which rows a signed-in user can read or change. In this kit the policies, not the app code, are what keep one customer's data away from another's. A query from the browser, a server component or the phone app runs as the signed-in user, and Postgres filters it.

    What is in place

    • RLS is on for all 13 tables in public. schema-conditions.test.sql raises an error if any table in public has it off, so a new table without it fails the tests.
    • Grants are narrow. Each table starts with revoke all from anon, authenticated and service_role, then grants back only what is needed. schemas/00-privileges.sql (part of the baseline migration) also strips the TRUNCATE, REFERENCES and TRIGGER privileges Supabase grants by default (TRUNCATE ignores RLS), and privileges.test.sql checks they stay gone.
    • Users cannot move rows between accounts. Where users may update a table, the grant lists the editable columns only, never id or account_id.
    • Billing tables are read-only for users. Signed-in users can SELECT their subscriptions, orders and billing customer rows. Only service_role can write them, which happens in the payment webhook after the provider's signature is checked. Nobody can give themselves a paid plan through the API.
    • Two-step sign-in is enforced in the database. Restrictive policies require a completed second step from users who set one up (see Two-step sign-in and passkeys).

    Roles and permissions

    Team members have a role. The seed (17-roles-seed.sql) creates two:

    RolePermissions
    ownerroles.manage, billing.manage, settings.manage, members.manage, invites.manage
    membersettings.manage, invites.manage

    Roles have a rank (hierarchy_level; a lower number is more senior). The team's primary owner can act on any member. Anyone else needs members.manage and can only act on members ranked below them, and nobody can act on the primary owner (can_action_account_member). You can add roles and change these permissions in your own migration.

    Helper functions

    Policies call these functions from public. Use them in your own policies rather than writing the joins again.

    FunctionTrue when
    has_role_on_account(account_id)The current user is a member of the account (optionally with a given role)
    has_permission(user_id, account_id, permission)The user's role on the account has the permission
    is_account_owner(account_id)The current user owns the account
    is_team_member(account_id, user_id)The user is a member of the team account
    can_action_account_member(account_id, user_id)The current user may act on that member (permission and rank check)
    has_active_subscription(account_id)The account has a subscription marked active
    is_super_admin()The user is a super admin and passed two-step sign-in
    is_mfa_compliant()The session meets the user's two-step requirement

    They are SECURITY DEFINER and granted to authenticated on purpose, because the policies call them as the signed-in user. Do not move them to another schema or revoke execute: every policy that uses them would fail.

    A typical policy for your own table:

    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)
      );
    

    The first line covers the user's personal account, the second any team they belong to.

    Files

    The storage bucket account_image (profile pictures and team logos) has its own policies in 16-storage.sql: each file is named after the account id, members of that account can read it, and only the owner of a personal account or a team member with settings.manage can upload, replace or delete it. See File uploads.

    What RLS does not cover

    • The admin client. getSupabaseServerAdminClient() uses the secret key and skips RLS. The kit uses it in server code for the payment and database webhooks, invitations, billing, one-time codes, account deletion and the admin panel. Code that uses it has to check permissions itself, so use it only when the normal client cannot do the job.
    • Public file URLs. The account_image bucket is public, so anyone with a file's URL can load it. Do not store private files there; create a private bucket with its own policies.
    • Other schemas. The RLS check in schema-conditions.test.sql covers the public schema only.

    How it is tested

    13 of the 23 pgTAP test files sign in as one user and try to read or change another account's data, and assert that it fails. See Database tests.