Preventing Cross-Tenant Data Leaks: Structure, Database Policies, and Probes
Where tenant data leaks come from, which controls catch each path, and a lab in which a popular tenant_id linter missed three unsafe queries and a structural rule caught them.
In a shared multi-tenant system, the most damaging bug is usually not an outage. It is one customer seeing another customer's data. These leaks rarely come from exotic attacks. They come from a query missing a tenant filter, a background job running without tenant context, a cache key without a tenant prefix, or an API endpoint that checks the object ID but not who owns it.
No single control prevents all of those. This guide describes layered controls (application structure, database policies, and tests that run against production) and is honest about what each one catches. The lab runs a commonly published "tenant_id linter" and a structural rule over eight realistic call sites; the string-matching linter missed three unsafe queries and flagged two safe ones.
Where leaks come from
| Path | Typical bug | Control that catches it |
|---|---|---|
| SQL in request handlers | Query filters by object ID only | Tenant-scoped data access layer, row-level security |
| API object lookups | GET /invoices/{id} returns any invoice whose ID is guessed |
Ownership check on every object access, cross-tenant tests |
| Background jobs | Job runs without restoring the tenant | Job payload carries tenant; worker sets context before work |
| Caches | Key is invoice:{id} instead of tenant:{t}:invoice:{id} |
Key helpers that require a tenant |
| Search and analytics | Shared index queried without a tenant filter | Per-tenant index or mandatory filter in the query builder |
| Exports and reports | Report query written by hand for one customer, reused for another | Same data access layer as the API |
OWASP lists the API version of this problem first in its API Security Top 10: Broken Object Level Authorization. The fix it describes, checking that the caller may access each object it requests, applies to every row above.
Layer 1: make the safe path the only path
The most effective control is structural. If application code cannot reach the database except through a package that requires a tenant, a missing filter becomes a compile error or an obvious code-review finding, not a silent bug.
In practice that means one package owns database access and exposes functions like tenantdb.Query(ctx, tenantID, sql, args...), which opens a transaction, sets the tenant for row-level security, and runs the query. Handlers never receive a raw *sql.DB. The row-level security guide shows such a wrapper.
Then you need a check that nobody bypasses it. That is where linters come in, and where the details matter.
Lab: two ways to lint for missing tenant filters
A common published approach inspects string literals passed to any function call: if a literal mentions a tenant table and does not contain tenant_id, report it. The alternative is structural: report any call to a raw database method (Query, QueryContext, Exec, and so on) made anywhere except the tenant-scoped package, regardless of the SQL text.
The program parses eight call sites with Go's standard go/parser and applies both rules using the same AST inspection a go/analysis analyzer would use. Run it with go run .; it uses only the standard library.
// Lab: what a string-matching tenant-scope linter catches.
//
// Runs the rule "a string literal argument that mentions a tenant table must
// also mention tenant_id" over eight small call sites and compares it with a
// structural rule: raw database methods may only be called from the package
// that owns tenant-scoped transactions.
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"strings"
)
const sample = `package handlers
const listInvoices = "SELECT id, total FROM invoices ORDER BY created_at DESC"
func cases() {
db.QueryContext(ctx, "SELECT id FROM invoices WHERE tenant_id = $1 AND id = $2", t, id) // 1
db.QueryContext(ctx, "SELECT id FROM invoices WHERE id = $1", id) // 2
log.Printf("failed to sync users for workspace %s", t) // 3
db.QueryContext(ctx, listInvoices) // 4
db.QueryContext(ctx, "SELECT * FROM "+table+" WHERE id = $1", id) // 5
db.QueryContext(ctx, "SELECT * FROM documents WHERE id = $1 /* tenant_id checked upstream */", id) // 6
q := fmt.Sprintf("DELETE FROM documents WHERE id = '%s'", id) // 7
db.ExecContext(ctx, q)
tenantdb.Query(ctx, t, "SELECT id FROM invoices WHERE id = $1", id) // 8
}
`
var tables = []string{"invoices", "documents", "users", "api_keys", "audit_logs"}
// stringRule is the commonly published check.
func stringRule(f *ast.File, fset *token.FileSet) map[int]bool {
hits := map[int]bool{}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
for _, table := range tables {
for _, arg := range call.Args {
lit, ok := arg.(*ast.BasicLit)
if ok && strings.Contains(strings.ToLower(lit.Value), table) &&
!strings.Contains(strings.ToLower(lit.Value), "tenant_id") {
hits[fset.Position(lit.Pos()).Line] = true
}
}
}
return true
})
return hits
}
// structuralRule: any Query*/Exec* method call on something other than the
// tenantdb package is a finding, whatever the SQL text looks like.
func structuralRule(f *ast.File, fset *token.FileSet) map[int]bool {
hits := map[int]bool{}
raw := map[string]bool{"Query": true, "QueryContext": true, "QueryRow": true, "QueryRowContext": true, "Exec": true, "ExecContext": true}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || !raw[sel.Sel.Name] {
return true
}
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "tenantdb" {
return true
}
hits[fset.Position(call.Pos()).Line] = true
return true
})
return hits
}
func main() {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "sample.go", sample, parser.ParseComments)
if err != nil {
panic(err)
}
s, st := stringRule(f, fset), structuralRule(f, fset)
cases := []struct {
line int
what string
unsafe bool
}{
{6, "1 literal query filtered by tenant_id", false},
{7, "2 literal query without tenant filter", true},
{8, "3 log message that mentions users", false},
{9, "4 query stored in a constant", true},
{10, "5 query built by concatenation", true},
{11, "6 'tenant_id' only appears in a comment", true},
{12, "7 Sprintf-built DELETE (also injectable)", true},
{14, "8 call through the tenant-scoped wrapper", false},
}
verdict := func(flagged, unsafe bool) string {
switch {
case flagged && unsafe:
return "flagged (correct)"
case flagged && !unsafe:
return "flagged (false positive)"
case !flagged && unsafe:
return "missed (false negative)"
}
return "not flagged (correct)"
}
fmt.Printf("%-44s %-26s %s\n", "call site", "string rule", "structural rule")
for _, c := range cases {
line := c.line
if c.line == 12 { // the Exec for case 7 is on the next line
line = 13
}
structural := verdict(st[line], c.unsafe)
if st[line] && !c.unsafe {
structural = "flagged (policy: use the wrapper)"
}
fmt.Printf("%-44s %-26s %s\n", c.what, verdict(s[c.line] || s[line], c.unsafe), structural)
}
}
Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64):
call site string rule structural rule
1 literal query filtered by tenant_id not flagged (correct) flagged (policy: use the wrapper)
2 literal query without tenant filter flagged (correct) flagged (correct)
3 log message that mentions users flagged (false positive) not flagged (correct)
4 query stored in a constant missed (false negative) flagged (correct)
5 query built by concatenation missed (false negative) flagged (correct)
6 'tenant_id' only appears in a comment missed (false negative) flagged (correct)
7 Sprintf-built DELETE (also injectable) flagged (correct) flagged (correct)
8 call through the tenant-scoped wrapper flagged (false positive) not flagged (correct)
What the output shows
The string rule judges text, not behavior, so it fails in both directions:
- It missed a query stored in a constant, because the call's argument is an identifier, not a literal.
- It missed a query built by concatenation, because the argument is an expression.
- It accepted a query that mentions
tenant_idonly in a comment. - It flagged a log message that happens to contain the word "users".
- It flagged a correct call through the tenant wrapper, because that query relies on row-level security instead of repeating the filter.
The structural rule flagged every unsafe call and ignored the wrapper. It also flagged the correctly filtered raw query (case 1), which is intended: the policy is "always use the wrapper", so that call should be moved too. To run the structural rule in CI, wrap it in a go/analysis analyzer, use type information to identify *sql.DB, *sql.Tx, and your driver's types instead of method names, and allow-list the one package that is permitted to call them.
Neither rule sees SQL generated by an ORM at run time, which is why the next layers exist.
Layer 2: database policies
PostgreSQL row-level security applies a tenant predicate inside the database, for every query run by a role subject to the policy, whatever SQL the application sends. The PostgreSQL documentation is explicit about the exceptions: superusers and roles with BYPASSRLS always bypass it, table owners bypass it unless the table uses FORCE ROW LEVEL SECURITY, and referential-integrity checks bypass it. Run the application under a dedicated role without those privileges, and test that role, not a superuser, in CI.
Row-level security does not help with data outside the database: caches, search indexes, object storage, and analytics systems each need their own tenant boundary. AWS's whitepaper SaaS Tenant Isolation Strategies walks through those boundaries for common services.
Layer 3: cross-tenant tests in CI
For every endpoint that takes an object ID, write a test that creates the object as tenant A and requests it, updates it, deletes it, and searches for it as tenant B. Assert that B gets the same response as for an object that does not exist, and that nothing changed. Generate these tests from your route table so a new endpoint cannot ship without one.
Decide what "does not exist" looks like. Returning 404 for both missing and foreign objects avoids confirming that an ID exists; returning 403 is acceptable if your API deliberately reveals existence. Either way, the body, headers, and response time should not differ in ways that reveal more than the status does.
Layer 4: synthetic probes in production
Tests run against test data and test configuration. A migration that disables a policy, a new read replica without the role setup, or a cache added by another team can break isolation only in production. Run a probe there:
- Keep two synthetic tenants, A and B, with non-sensitive fixture data.
- On a schedule, create a record as A containing a random marker.
- As B, try to read, update, delete, and search for that record through the public API.
- Fail the probe if B sees the marker, gets anything other than the expected response, or changes the record. Delete the fixture afterwards.
- Alert on probe failures and on probes that stop running.
Managed tools such as Amazon CloudWatch Synthetics canaries can schedule the requests; the assertions are yours. Store each result with the build version and test definition. The audit log guide describes how to make such a record tamper-evident.
A passing probe shows that the tested paths were isolated at the times they ran. It does not show that every path is safe. Present it that way to customers and auditors.
If a probe or customer reports a leak
Treat it as a security incident from the first signal: page the owning team, preserve logs, and contain first (disable the endpoint or feature) before investigating. Work out which tenants' data was exposed, to whom, and for how long, using access logs. Notification obligations depend on contracts and law in the affected jurisdictions; involve legal counsel early. Keep customer data out of the incident channel and tickets.
Where to start
Run the structural rule from the lab over your codebase and count raw database calls outside your data access package. That number is your exposure surface. Then add one cross-tenant CI test generator for your API routes, confirm your application role cannot bypass row-level security, and schedule a production probe for your most sensitive endpoint. The isolation model guide helps if some tenants need a stronger boundary than shared tables provide.
Frequently Asked Questions
Should a foreign object return 403 or 404?
Return 404 if you do not want to confirm that an ID exists, which suits most multi-tenant APIs. Return 403 if your API deliberately reveals existence. Whichever you choose, make the body, headers, and timing consistent with that choice.
Is row-level security enough on its own?
No. It protects queries made by roles subject to the policy, inside that database. It does not cover privileged roles, other datastores, caches, or exports, and it will happily enforce a policy that is itself wrong. Combine it with application structure and tests.
How often should production probes run?
Often enough that a regression is found before much data could be exposed, and after every deployment. For most services that means on every deploy plus every few minutes to every hour, depending on sensitivity and cost.
Can probes use real customer data?
They should not. Use dedicated synthetic tenants and generated data, restrict the probe's credentials to those tenants, and exclude them from customer analytics and billing.
Do UUIDs make object IDs safe to expose?
They make IDs hard to guess, but IDs leak through URLs, logs, and shared links. Unguessable IDs reduce casual enumeration; they do not replace an ownership check on every access.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
API1:2023 Broken Object Level Authorization
(OWASP API Security Project)
• Link checked 2026-11-03
Object-level authorization failures and how to prevent them.
-
Row Security Policies (PostgreSQL 18 documentation, section 5.9)
(PostgreSQL Global Development Group)
• Link checked 2026-11-03
Default deny, bypass rules for superusers, BYPASSRLS roles, owners, and referential integrity checks.
-
analysis package
(The Go Authors)
• Link checked 2026-11-03
Framework for writing Go static analyzers that run in CI and go vet.
-
SaaS Tenant Isolation Strategies
(Amazon Web Services)
• Link checked 2026-11-03
Isolation boundaries across compute, storage, and managed services.
-
Synthetic monitoring (canaries)
(Amazon Web Services)
• Link checked 2026-11-03
Scheduling scripted requests against production endpoints.
Revision history
- — Added a reproducible lab comparing a string-matching linter with a structural rule. Removed an invented churn statistic, corrected the description of what the previous linter checked, replaced mislabeled sources (including an incorrect SOC 2 criterion), and added cross-tenant CI tests and incident steps.
- — Replaced isolation guarantees with layered evidence and risk-based testing.