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

> Source: <https://dev.to/benjy33000/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves-it-3gkb>
> Published: 2026-10-01 07:07:03+00:00

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