# Multi-tenant SaaS architecture: teams, roles and invitations done right

> Multi-tenant SaaS architecture on Supabase: accounts as tenants, memberships with roles, permissions checked in the database, invites tied to one email.

Source: https://shipanysaas-demo.vercel.app/blog/multi-tenant-saas-teams-roles-invitations
Published: 2026-10-18 · Updated: 2026-10-18

A multi-tenant SaaS runs one app and one database for every customer and keeps each customer's data apart with a tenant id on every row. In a B2B product the tenant is usually a team, so the architecture needs accounts, memberships with roles, permissions checked in the database, and invitations that can't be forged or reused.

The sign-in page is the easy part. The hard part comes after it: who belongs to which team, what each person may do there, and how a stranger's email address becomes a member without opening a way into someone else's data. Below, each piece is shown as shipanysaas builds it on Supabase, with file names, so you can copy the shape or check your own.

## How does multi-tenancy work?

Every customer shares the same code and database, and every row a customer owns carries their tenant id. A rule that runs on every query, not each developer's memory, keeps one tenant from seeing another's rows.

In a Supabase app that rule is row-level security. The browser and phone app query the database directly, so the database checks each request: is the signed-in user a member of the account in this row's `account_id`? If not, the row doesn't exist for them. Your code still filters by account, but a forgotten filter returns nothing instead of another customer's data. [Supabase RLS explained](https://shipanysaas-demo.vercel.app/blog/supabase-rls-explained) covers the policies themselves.

## What is the difference between multi-tenant and single-tenant?

Multi-tenant means all customers share one deployment and one database, separated by a tenant id. Single-tenant means each customer gets their own database, or their own whole deployment.

| | Multi-tenant (shared database) | Single-tenant (database per customer) |
| --- | --- | --- |
| Isolation | A rule on every query, such as row-level security | Separate databases or servers |
| Migrations and backups | Run once | Run once per customer |
| Cost per new customer | Close to nothing | A new database or deployment |
| Cross-customer reporting | One query | Gather from every database |
| Typical fit | Self-serve SaaS, small and mid-size teams | Contracts that demand physical separation or data residency |

Most SaaS products start multi-tenant and move a single large customer to dedicated infrastructure only when a contract requires it. If the tenant id is on every row from day one, that move is an export, not a rewrite.

## Supabase multi-tenant architecture: the tenant is the account

The kit keeps both kinds of tenant in one `accounts` table and points everything else at it. Here is the shape, from `apps/web/supabase/schemas`:

```text
auth.users  (Supabase Auth)
   |
   |  sign-up trigger creates one personal account with the same id
   v
accounts  (03-accounts.sql)
   |-- personal account   is_personal_account = true,  id = auth.users.id
   '-- team account       is_personal_account = false, slug, primary_owner_user_id
   ^
   |  account_id on every tenant row
   |
   |-- accounts_memberships (05)   one row per person per team
   |     user_id -> auth.users
   |     account_role -> roles.name
   |     primary key (user_id, account_id)
   |
   |-- invitations (07)            team accounts only
   |     email, role -> roles.name, invited_by, invite_token
   |     expires_at = 7 days, unique (email, account_id)
   |
   '-- billing_customers, subscriptions, orders, notifications, your tables

roles (04)                          role_permissions (06)
  name, hierarchy_level (unique)      role -> roles.name
  owner  = 1                          permission (app_permissions enum):
  member = 2                            roles.manage, billing.manage,
                                        settings.manage, members.manage,
                                        invites.manage
```

- **Personal accounts.** A trigger creates one for each user at sign-up, with the same id as the user in `auth.users`.
- **Team accounts.** Shared workspaces with a name, a slug for the URL (`/home/<slug>`) and a primary owner. `create_team_account` creates the team and the owner's membership in one call, so a team never starts without its owner.

Because every tenant table points at `accounts`, a user's own data and their team's data follow one pattern. Someone can start alone and join teams later without moving anything. Billing attaches to the account too, so a team's subscription belongs to the team, not to whoever typed in the card. If your product has no use for personal workspaces, `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true` sends users straight to a team.

The role lives on the membership row (simplified from `05-memberships.sql`):

```sql
create table public.accounts_memberships (
  user_id uuid not null references auth.users on delete cascade,
  account_id uuid not null references public.accounts (id) on delete cascade,
  account_role varchar(50) not null references public.roles (name),
  primary key (user_id, account_id)
);
```

One row per person per team means the same person can own one team and be a plain member of another. A single `role` column on the users table can't express that.

## Role-based access control: roles, ranks and permissions

Role-based access control means your code asks "does this person's role allow this action?" instead of checking people one by one. The seed (`17-roles-seed.sql`) creates two roles:

| Role | Rank | Permissions |
| --- | --- | --- |
| `owner` | 1 | All five |
| `member` | 2 | `settings.manage`, `invites.manage` |

There is no admin role out of the box. To add one, write a migration that inserts it into `roles` and `role_permissions`; ranks are unique, so move `member` down a level to make room. The app checks permissions and ranks rather than role names, so a new role is a migration, not a code change.

Permissions are checked by the database. `has_permission(user_id, account_id, permission)` runs inside row-level security policies: changing or deleting a pending invitation needs `invites.manage`, and uploading a team logo needs `settings.manage`. Pages read the same permissions to show or hide controls, but a hidden button is a courtesy. The policy is the lock.

Ranks cover what a permission alone can't:

- The primary owner can act on any member, and nobody can remove or demote the primary owner.
- Everyone else needs `members.manage` and can only act on members ranked below them (`can_action_account_member`).
- Nobody can invite someone to a role above their own, or move a member into one.

### Why not put the role in the JWT?

Supabase's [custom claims and RBAC guide](https://supabase.com/docs/guides/api/custom-claims-and-role-based-access-control-rbac) adds a `user_role` claim to the access token with an auth hook. That suits app-wide roles. In a multi-tenant SaaS the role depends on which team you are looking at, so one claim per user doesn't fit. Tokens also go stale: Supabase's [row-level security docs](https://supabase.com/docs/guides/database/postgres/row-level-security) warn that a removed team membership won't show in `auth.jwt()` until the user's token refreshes. Reading memberships from the table applies a removal on the next request.

## Team invitations, step by step

Invitations are where hand-built versions tend to leak, because an invite link is a way into another tenant. The kit's flow, from `packages/features/team-accounts` and the `/join` routes:

1. **Someone with `invites.manage` invites up to five emails at once, each with a role.** The server action checks the permission and the rank, runs any custom invitation policies, and refuses addresses already on the team.
2. **Only server code writes the invitation.** Signed-in users have no insert grant on `invitations`, so the browser can't create one. After its checks pass, the action calls `add_invitations_to_account` with the admin client.
3. **Each invitation is a row** with the email, role, sender, a random token and an expiry 7 days out. An email can be invited to a team once, and a trigger refuses invitations to personal accounts.
4. **The email links to `/join/accept`** with the token and a signature: an HMAC made on the server with a key derived from `SUPABASE_SECRET_KEY`.
5. **A valid signature means a one-click join.** The route makes a fresh Supabase sign-in link for the invited address: a magic link if the person has an account, an invite link that creates one if not. Without a valid signature, the link still works, but only through normal sign-up or sign-in.
6. **`/join` checks the email.** The invitation's address must match the signed-in user's, ignoring case. If it doesn't, the page says the invitation was not found or has expired.
7. **Accepting is one database function.** `accept_invitation` adds the membership with the invited role and deletes the invitation; an expired token fails. On a per-seat plan, the seat count is then set to the new member count.

Edge cases, handled on purpose:

- **Expired links.** From the members page you can renew a pending invitation for another 7 days, change its role or delete it.
- **Leaked tokens.** Team members can't read the token column through the API, and the token alone can't sign anyone in without the signature.
- **The wrong person clicking.** An invitation only works for the address it was sent to.
- **Business rules.** `packages/features/team-accounts/src/server/policies/policies.ts` is a registry for rules like "the team must have an active subscription". One is written as an example; none are switched on.

## Ownership, leaving and deleting

- **Transfer.** The primary owner can hand the team to an existing member after entering a one-time code sent by email.
- **Delete.** The primary owner can delete the team, also with an emailed code, when `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true`. Codes work once and are revoked after 5 wrong attempts.
- **Leave.** Any member except the primary owner can leave.

## Test the requests your UI never sends

Clicking through invites by hand only proves the happy path. The holes are in requests your UI never makes, and the kit's pgTAP files `team-accounts`, `memberships`, `invitations`, `update-membership`, `delete-membership` and `transfer-ownership` make them. `invitations.test.sql`, for example, tries to insert an invitation directly as the owner, a plain member, a member whose custom role holds `invites.manage`, and an outsider, and checks that all four fail. It also checks that no signed-in user can read the token column. Playwright specs in `apps/e2e/tests/team-accounts` and `apps/e2e/tests/invitations` cover the pages. [How the kit keeps one customer's data away from another's](https://shipanysaas-demo.vercel.app/blog/keeping-customer-data-apart) lists the rest.

## FAQ

### Is Supabase good for multi-tenant SaaS?

Yes, for the shared-database model. Postgres row-level security enforces the tenant rule on every query, and Supabase Auth supplies the signed-in user the policies check. You still design the account, membership and role tables yourself, or start from a kit that has them.

### Can one person be in several teams with different roles?

Yes. The role is stored on each membership, so someone can own one team and be a member of another. The account switcher moves between their personal account and their teams.

### What if someone signs in with a different email than the one invited?

The invitation is tied to the address it was sent to, so it won't match. The emailed link signs them in as the invited address; otherwise they need to sign in with that address, or you can invite the one they actually use.

### Do I need an admin role?

Not to start. Owner and member cover a small team. Add an admin with a migration when you need someone who can manage members without touching billing.

The full reference is [Teams and invitations](https://shipanysaas-demo.vercel.app/docs/features/teams-and-invitations) in the docs. To click through the members page yourself, the [live demo](https://shipanysaas-demo.vercel.app/auth/sign-in) has a sample team, Acme Inc, with members and two pending invitations.
