GoVueKit

Row-level multi-tenancy in Go: the rule, the middleware and the test that proves it

June 10, 2026

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.

The rule

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.

Step 1: the route carries the tenant

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.

Step 2: one middleware resolves membership

// 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.

Step 3: 404, not 403

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.

Step 4: the queries cannot be called wrong

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.

Step 5: roles are checked server-side

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.

Step 6: the test that keeps it true

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.

What this design does not give you

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.

The five-line summary for your own codebase

  1. Put organization_id on every business table, with a cascade and an index.
  2. Resolve membership in one middleware, from the URL, after authentication.
  3. Answer 404 to outsiders, 403 only inside an organization the caller belongs to.
  4. Write queries that take the tenant, so the generated code refuses to be called without it.
  5. Write the cross-tenant test once per resource, and run it in CI.

See it in the code excerpts on the landing page, or read how the same discipline applies to billing webhooks.