GoVueKit

Multi-tenant SaaS in Go with chi and sqlc: the whole tenancy layer

September 12, 2026

Row-level multi-tenancy is the least glamorous security boundary in a B2B SaaS and the one most often broken by a Tuesday afternoon commit: a new query, a missing WHERE organization_id = $1, and one customer reads another's data. Row-level multi-tenancy in Go explained the design; this is the layer itself, as it ships in GoVueKit, file by file.

The model

Three tables carry tenancy: organizations, org_members (organization, user, role) and every business table, which carries organization_id. Identifiers are TEXT, generated in Go (UUIDs), so the schema is identical on PostgreSQL and SQLite and nothing depends on a sequence.

Roles are three strings with an order: owner > admin > member. There is no permission matrix, because a boilerplate that ships one ships a maintenance burden; a product that needs one adds it next to RoleAtLeast.

The query that decides everything

-- name: GetOrgMember :one
SELECT * FROM org_members WHERE organization_id = $1 AND user_id = $2;

sqlc turns it into a typed Go method. Every request that names an organization goes through it, once, before any handler runs.

The middleware

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
		}
		if err != nil {
			o.Log.Error("org middleware: load membership", "error", err)
			writeError(w, http.StatusInternalServerError, "internal error")
			return
		}
		organization, err := o.Q.GetOrganization(r.Context(), orgID)
		if err != nil {
			writeError(w, http.StatusNotFound, "not found")
			return
		}
		ctx := context.WithValue(r.Context(), orgKey, OrgCtx{Org: organization, Role: m.Role})
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Three things to notice.

404, not 403, for a non-member. A 403 says "this exists and you may not see it", which is exactly the enumeration oracle an attacker wants when guessing organization ids. To an outsider, the organization does not exist. The same status is returned whether the id is wrong or the membership is missing, so the two cases are indistinguishable from outside.

The membership row is the tenant context. The handler downstream never re-derives who the user is or what they may do; it reads OrgFrom(ctx) and gets the organization and the role. There is one place to get tenancy wrong, and it is thirty lines long.

Errors are boring on purpose. A database failure is a 500 with a log line, never a 404 that would make an outage look like a permission problem.

The role check, after the tenant check

func RequireOrgRole(minRole string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			oc, ok := OrgFrom(r.Context())
			if !ok || !org.RoleAtLeast(oc.Role, minRole) {
				writeError(w, http.StatusForbidden, "insufficient permissions")
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

Here a 403 is correct: the caller is a member, the organization is not a secret to them, they simply lack the role. The order matters — membership first (404), then role (403) — and the router enforces it structurally.

The route group

api.Route("/orgs", func(or chi.Router) {
	or.Route("/{orgID}", func(one chi.Router) {
		one.Use(orgMW.Context)
		one.With(middleware.RequireOrgRole(org.RoleAdmin)).Patch("/", orgsH.Update)
		one.With(middleware.RequireOrgRole(org.RoleOwner)).Delete("/", orgsH.Delete)
		one.With(middleware.RequireOrgRole(org.RoleOwner)).Patch("/members/{userID}", orgsH.ChangeRole)
		one.With(middleware.RequireOrgRole(org.RoleAdmin)).Delete("/members/{userID}", orgsH.RemoveMember)
		one.With(middleware.RequireOrgRole(org.RoleOwner)).Post("/billing/checkout", billingH.Checkout)
		// ...
	})
})

Everything under /api/orgs/{orgID} inherits orgMW.Context. A new tenant-scoped endpoint cannot be mounted outside the group by accident, because the group is where {orgID} is defined. The role requirement sits on the route line, next to the verb, where a reviewer sees it.

The convention for business tables

When you add your first resource — the ten-minute tour walks a projects table through every layer — the queries take this shape:

-- name: GetProject :one
SELECT * FROM projects WHERE organization_id = $1 AND id = $2;

-- name: ListProjects :many
SELECT * FROM projects WHERE organization_id = $1 ORDER BY created_at DESC;

The handler passes OrgFrom(ctx).Org.ID as the first argument, always. Because sqlc generates a typed parameter struct per query, a query that lacks the tenant column also lacks the parameter, which is visible in the generated signature and in review. It is not a guarantee; it is a convention that is hard to violate silently, which is what a small codebase can afford.

Lists of organizations themselves go through membership too: GET /api/orgs returns the organizations the user belongs to, nothing else, so an outsider does not even learn that an id exists.

The test that keeps it true

Unit tests on the middleware are cheap and exist. The one that matters runs against the production binary in Playwright, with three real browsers:

// RBAC: the member hits an admin-only route → 403, server-side.
const patch = await member.request.patch(`/api/orgs/${orgId}`, {
  data: { name: 'Hacked' },
  headers: { 'X-CSRF-Token': await csrf(member) },
})
expect(patch.status()).toBe(403)

// Cross-tenant isolation: an unrelated user gets 404 on the org, and
// never sees it in their list.
const probe = await stranger.request.get(`/api/orgs/${orgId}`)
expect(probe.status()).toBe(404)
const list = await (await stranger.request.get('/api/orgs')).json()
expect(list.organizations).toHaveLength(0)

An owner creates the organization and invites a member; the member accepts from the email and gets a 403 on an admin action; a stranger gets a 404 and an empty list. Remove the WHERE clause, or mount a route outside the group, and CI goes red on both database engines.

What this does not cover, and why

No PostgreSQL row-level security policies: they would make the SQLite target a second-class citizen and hide the boundary in the database, where a Go reviewer does not look. No per-tenant schemas or databases: at the scale a boilerplate targets, one schema with an indexed organization_id is faster to operate and to back up. The 404-for-outsiders rule itself is recorded in the kit's DECISIONS.md, which is public.

See it run, three browsers included, in the labs:

curl -fsSLO https://govuekit.dev/labs/docker-compose.yml
docker compose up -d