Guide
Multi-tenancy in SvelteKit, at the application layer
Most multi-tenancy advice starts with database features you may not have (RLS, schema-per-tenant) or vendor products you may not want. For small B2B SaaS there's a third path: tenancy as plain application logic, enforced where your queries are built. This guide shows the patterns we ship in our multi-tenant SvelteKit starter — each one backed by tests you can read.
What "multi-tenant" means at the application layer
One shared schema, every tenant-scoped row carries an org_id,
and the scope is enforced where queries are constructed — never trusted
from the client. That's the whole idea. Row-Level Security moves the same
discipline into the database engine; IdP org features move it into a
vendor. App-layer tenancy keeps it in your codebase where your product
logic already lives, which is also why it ports across databases when
SQLite-on-a-laptop becomes Postgres-under-load.
The tradeoff is honesty about responsibility: no engine will catch a forgotten WHERE clause for you. You compensate with structure — services that own all writes, indexes that make duplication impossible, and tests that try to break scope boundaries the way an attacker would.
Modeling organizations, membership, and roles
Three tables carry most products surprisingly far:
organizations(id, name, slug, created_at_ms)
memberships(id, org_id → organizations, user_id → users,
role ∈ 'owner'|'admin'|'member', created_at_ms)
invites(id, org_id, token_hash, role, invited_by_user_id,
expires_at_ms, accepted_at_ms?, revoked_at_ms?) Decisions that bite later, made deliberately here:
- Role lives on the membership, not the user. A person is an admin of this org, period. Moving them between orgs means moving rows, not mutating identity.
- One membership per (org, user), enforced by unique index. Concurrent invite acceptance can't create duplicates because the schema says no.
- Owner is a role, not a flag. Ownership transfer becomes an ordinary role change under hierarchy rules — two UPDATEs in one transaction instead of special-case columns.
- Invites reference the inviter. When you ask "who let this person in?" six months later, the answer is a foreign key, not archaeology.
Invite flows that don't leak access
An invite link is a bearer credential with a delivery problem. Treat it like one:
- The link contains 32 bytes of entropy; the database stores only its SHA-256. Shown once in the UI, then unrecoverable — by construction.
- Expiry is a column compared against now(), not a cron job.
- Single-use means conditional update:
UPDATE invites SET accepted_at_ms = ?
WHERE id = ? AND accepted_at_ms IS NULL
AND revoked_at_ms IS NULL AND expires_at_ms > ?
-- zero rows updated = someone else got there first Two people clicking simultaneously is a normal Tuesday, not a race condition to debug — exactly one wins, the other gets an honest "already used." Revocation rides the same predicate. And when the org is at its seat limit, acceptance fails closed at join-time with a human-readable error, because billing state changed between link creation and click.
Enforcing roles on every request
Hidden buttons are user experience. Authorization is what the server
decides after ignoring everything the client sent. In practice: every
page load re-reads the actor's membership from the database; every form
action re-checks the specific permission (members.invite, billing.manage, …) against a capability matrix — three
roles mapped to explicit capabilities, no inheritance surprises. Two
rules do heavy lifting beyond the matrix:
- Strict downward-only hierarchy: admins manage members, owners manage admins, and no rank can grant anything at or above itself. This kills the classic self-promotion hole where an admin endpoint accepts
role=admin. - Last-owner protection: leave-org and remove-member paths check owner count first, so an org can never strand without one.
Each mutation writes its audit row in the same service call — who did what to whom, in an append-only table with no update or delete codepath. Enterprise pilots ask for this in the first week; retrofitting it onto untested mutations is how weeks disappear.
Checklist: shipping your own tenancy layer
- Every tenant-scoped row carries org_id with a composite unique index where duplication would be fatal — e.g. one membership row per (org_id, user_id), enforced by the schema, not by hope.
- Sessions live in your database and store only sha256(token) — a leaked sessions table yields zero usable logins.
- Invite tokens: random 32 bytes, stored hashed, expiry timestamp, single-use enforced by a conditional UPDATE — never read-then-write.
- Permissions checked server-side on every load AND every action, from fresh DB state. UI hiding is UX, not authorization.
- A role hierarchy rule in one place: actors act only strictly downward; nobody grants, promotes, or transfers to their own rank.
- The last owner cannot leave or be removed — enforce it in the service layer where you can wrap checks and writes together.
- An append-only audit row is written inside the same service call as the mutation it records — not in a separate code path someone forgets to call.
- Milliseconds everywhere, UTC only. Timezone math belongs at the display edge, nowhere else.
- Rate-limit failed auth attempts before launch — even a naive per-key counter beats nothing, and the seam takes an afternoon to swap later.
Or skip the line: the starter kit implements everything above with 52 tests over the auth, invite, RBAC, seat, and audit paths.
Join the waitlist
No spam, no drip sequence. One email when it’s real.