Skip to content

Testing tenant isolation on every endpoint, on every pull request

Multi-tenant leaks are not caused by hard problems. They are caused by one handler that forgot a where clause — so the test has to be mechanical.

Engineering2 min read

Document type
Security
Document
ACT-WEB

Cross-tenant data leaks are rarely exotic. They are almost always one handler, usually a new one, usually written on a Friday, that looks up a row by its id and forgets that ids are guessable and that the caller belongs to an organisation.

Code review catches most of these. Most is not a standard you can describe to a customer, so the check has to be structural: a test that enumerates the routes rather than a test someone remembers to write.

The shape of the test

Two organisations are created with a full set of fixtures. Then, for every route the router knows about, the suite calls it with organisation A's credentials and organisation B's resource ids, and asserts that the response is a 404 — not a 403, which confirms the row exists.

Enumerating the router, not a hand-written list
for _, rt := range router.Routes() {
    rt := rt
    t.Run(rt.Method+" "+rt.Pattern, func(t *testing.T) {
        t.Parallel()

        req := buildRequest(t, rt, orgB.Fixtures)  // B's ids
        req.Header.Set("Authorization", orgA.Token) // A's identity

        res := doRequest(t, req)
        if res.StatusCode != http.StatusNotFound {
            t.Fatalf("cross-tenant access: got %d, want 404", res.StatusCode)
        }
    })
}

The important property is that adding a route adds a test. A developer cannot forget to include their new endpoint, because they never included any endpoint — the suite reads the router. A route that genuinely has no tenant scoping, like a health check, has to be added to an explicit allowlist, and that allowlist is small enough to read in a code review.

Why 404 and not 403

Returning 403 for a resource in another tenant confirms that the id exists. That is a small leak, but it is a real one: it turns an id space into an oracle you can enumerate. Outside your organisation, a row does not exist, and the response should say so.

The database is the backstop, not the control

Our API connects to Postgres as a privileged role, which means row-level security policies keyed on the session user do not protect that path — the policies simply do not apply to it. Anyone who tells you RLS is their multi-tenancy story should be asked which role their application connects as.

We still enable RLS on every table in the public schema, because the cost is nothing and the default then becomes deny for any other client that ever reaches the database: a studio session, a future PostgREST layer, an analytics tool someone connects in a hurry. The application is the control. The database is the door that stays shut when someone forgets the application exists.

Isolation you have tested on every endpoint is a property. Isolation you have described in a security page is a hope.

We write the page before we build the feature.

It is the cheapest place to find out that a design does not explain itself. If something here does not match what you see in the product, tell us — that is a bug in one of the two.

Programme
Pilot
Places
Limited
Reply
Every enquiry

Pilot partners get their industry pack built with them, and direct access to the engineer writing it.