Row-level multi-tenancy in Go: the rule, the middleware and the test that proves it A developer has published the design for row-level multi-tenancy in GoVueKit, a Go SaaS kit, in which every business table carries an organization_id and every query filters on it using a value pulled from the request context rather than the client. Tenant-scoped routes are mounted under /api/orgs/{orgID}/…, where a membership lookup middleware acts as the authorization check and returns 404 rather than 403 for non-members so organization IDs cannot be enumerated. The kit uses sqlc instead of an ORM so that bypassing tenancy requires writing a new SQL statement that surfaces as a visible file change in review. Multi-tenancy has three common implementations: a database per tenant, a schema per tenant, and a column on every row. For a SaaS that starts small and must stay operable by one person, the column wins — provided the rule is enforced in one place rather than remembered in fifty. Here is the whole design, as implemented in GoVueKit, and the reasoning behind each choice. Every business table has an organization id . Every query filters on it. The value comes from the request context, never from the client. Three sentences, and they are the entire security model for tenancy. The implementation exists to make violating them awkward. Tenant-scoped routes live under /api/orgs/{orgID}/… . That is deliberate: the tenant is part of the URL, so it is visible in logs, in tests, and in the router itself. The alternative — an implicit "current organization" in the session — makes every request depend on hidden state, and makes tab-switching users a bug report. // Context loads the organization and the caller's membership. Non-members // get a 404 — indistinguishable from a nonexistent org, so org IDs never // leak across tenants. Must be mounted after RequireAuth. func o Org Context next http.Handler http.Handler { return http.HandlerFunc func w http.ResponseWriter, r http.Request { u, ok := UserFrom r.Context if ok { writeError w, http.StatusUnauthorized, "authentication required" return } orgID := chi.URLParam r, "orgID" m, err := o.Q.GetOrgMember r.Context , sqlcgen.GetOrgMemberParams{ OrganizationID: orgID, UserID: u.ID, } if errors.Is err, sql.ErrNoRows { writeError w, http.StatusNotFound, "not found" return } // ... load the organization, put {Org, Role} in the context } } The membership lookup is the authorisation check. If the caller is not a member, the request dies here — before any handler, before any query, before any chance to forget. A 403 says: this organization exists, and you are not in it. That is an information leak, and a useful one for an attacker enumerating ids. A 404 says nothing at all. The same reasoning applies inside a tenant: a resource that belongs to another organization is not "forbidden", it is not found , because from the caller's point of view it does not exist. The exception is roles inside an organization the caller is a member of. There, 403 is correct: the member knows the organization exists, they can see the button is disabled, and hiding the difference would only confuse them. sqlc generates a function per SQL statement. Write the statement with the tenant in the WHERE clause and the generated signature demands it: -- name: DeleteOrgInvitation :exec DELETE FROM org invitations WHERE id = $1 AND organization id = $2; err := q.DeleteOrgInvitation ctx, sqlcgen.DeleteOrgInvitationParams{ ID: invID, OrganizationID: orgCtx.Org.ID, } A developer who wants to bypass tenancy has to write a new SQL statement, which shows up in review as a file change in internal/db/queries/ . That is the property you want: the unsafe path is visible, not convenient. This is also why the kit uses sqlc rather than an ORM. With a query builder, Invitation.find id compiles, runs, and returns another tenant's row. There is no equivalent of that mistake here, because there is no generic finder. one.With middleware.RequireOrgRole org.RoleOwner .Delete "/", orgsH.Delete The frontend also hides the delete button, and that check is explicitly cosmetic. Anyone can open the console and call the endpoint; the middleware is what answers. One rule that is easy to miss: the last owner of an organization cannot be demoted, removed, or leave. Without it, organizations become unadministrable and support tickets become database surgery. Every one of the above can be undone by a well-meaning refactor. So the property gets a test at the level where it actually matters — through HTTP, against a real database: 1. Create user A and organization A. 2. Create user B and organization B. 3. As B, GET /api/orgs/{orgA} → 404 4. As B, GET /api/orgs/{orgA}/members → 404 5. As a member not owner of A, PATCH /api/orgs/{orgA}/members/{userID} → 403 Steps 3 and 4 are the ones that matter. They are cheap to write once and they fail loudly the day someone adds a query without the tenant. In GoVueKit this exists twice: as integration tests run against both database engines in CI, and as a Playwright journey against the production binary. Write it at the same time as the resource, never afterwards — a test you must remember to add later is a test that gets added after the incident. Be clear about the limits before you adopt it: In exchange you get one database to operate, migrations that run once, and the ability to answer "how many organizations are on the paid plan?" with a single GROUP BY . For the overwhelming majority of SaaS products, that trade is correct — and if you outgrow it, you outgrow it with revenue. organization id on every business table, with a cascade and an index. See it in the code excerpts on the landing page https://govuekit.dev/ code , or read how the same discipline applies to billing webhooks https://govuekit.dev/blog/idempotent-billing-webhooks . Originally published on govuekit.dev https://govuekit.dev/blog/row-level-multi-tenancy-go . GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one docker compose up : https://govuekit.dev/labs https://govuekit.dev/labs