Security Engineering

Multi-Tenant Codebases: Make the Unsafe Query Impossible to Write

David Guzenburg/ / 9 min read

A prompt influences a model. Tenant isolation has to hold regardless of what the model does, because one failure is a breach notification rather than a bug report.

multi-tenancyisolationsecuritytesting

Tenant isolation is not a prompting problem

The instinct when an agent works on multi-tenant code is to write careful instructions: always filter by tenant, never cross a boundary, be careful with shared tables. Those instructions are worth writing and they are not a security control.

A prompt influences a model. Multi-tenant isolation is a property that must hold regardless of what any model does, because the consequence of a single failure is one customer reading another's data — which is a breach notification, not a bug report.

So the instructions are the second line. The first is making the wrong thing hard to write.

Make the unsafe query the awkward one

The dangerous pattern is a query that can omit the tenant filter. If your data access layer allows it, an agent will eventually write it — not maliciously, just while focused on something else.

# Nothing stops a caller forgetting the filter.
def get_orders(session, status: str) -> list[Order]:
    return session.query(Order).filter_by(status=status).all()
# Tenant comes from the request context, not from an argument,
# and there is no code path that omits it.
def get_orders(status: str) -> list[Order]:
    return (
        tenant_session()                 # raises if no tenant in context
        .query(Order)
        .filter_by(status=status)
        .all()
    )

def tenant_session() -> Session:
    tid = current_tenant.get()           # ContextVar, set by middleware
    if tid is None:
        raise NoTenantContext("No tenant bound to this request.")
    return session_factory(tenant_id=tid)   # filter applied at the session

The second version cannot produce an unfiltered query, because the filter is not a parameter anyone can forget. That is a structural property, and it holds whatever the agent writes.

Push it to the database where you can

Row-level security enforces the same invariant one layer lower, where application bugs cannot reach it. If your database supports it, an RLS policy on every tenant-scoped table is the strongest version of this control and the one that survives a mistake in your data access layer.

Then write the instructions

With structure in place, instructions become a useful second layer — they stop the agent writing something that would be rejected, rather than being the only thing standing between you and a breach.

## Multi-tenancy — read before touching data access
Every row in `orders`, `invoices`, `documents`, `audit_log` and
`attachments` belongs to exactly one tenant.

- NEVER construct a query against these tables directly. Use the
  helpers in `db/tenant_scope.py`, which bind the tenant from the
  request context.
- NEVER pass tenant_id as a function argument. It comes from the
  authenticated principal, and an argument is something a caller
  can get wrong.
- NEVER use `session.execute(text(...))` on a tenant table. Raw SQL
  bypasses the scoping. If you genuinely need raw SQL, stop and ask.
- Cache keys MUST include the tenant. `f"orders:{status}"` is a
  cross-tenant cache poisoning bug. Use `cache_key()`.
- Background jobs: the tenant must be an explicit part of the job
  payload and re-bound at the start of the handler. Context does
  not survive the queue.

If a task seems to require reading across tenants, stop and say so.
That is an admin operation with a separate, audited code path.

The cache line is the one teams most often miss, and it is a genuine cross-tenant leak with no database query involved at all. The background-job line is second — context variables do not cross a queue boundary, and a handler that assumes they do reads whichever tenant happened to be bound last.

The places isolation actually leaks

SurfaceThe leakStructural fix
Cache keysKey omits tenant; second tenant reads first's valueA cache_key() helper that always includes it
Background jobsContext lost across the queueTenant in the payload; handler re-binds and asserts
Search indexesOne index, filter applied at query timeIndex per tenant, or a mandatory filter in the client wrapper
File storagePredictable paths, no ownership checkTenant in the path prefix and checked on read
Error messages"Order ord_123 not found" confirms it existsIdentical response for not-found and not-yours
Aggregates and reportsA COUNT across all rowsSame scoped session as everything else

The error-message row is subtle and real. Distinguishing "does not exist" from "exists but is not yours" is an enumeration oracle, and it is exactly the sort of helpful distinction generated code adds without being asked.

Admin paths need the same treatment

Every multi-tenant system has legitimate cross-tenant operations: support tooling, billing reconciliation, platform analytics. They are where isolation is supposed to be crossed, which makes them the most dangerous code in the repository.

## Cross-tenant code
Lives ONLY in `src/admin/`. Nothing outside that directory may
construct an unscoped session.

- Every function there is decorated `@requires_platform_role` and
  `@audited`. Both. No exceptions.
- The audit record includes: actor, tenants touched, justification
  string, timestamp. The justification is a required argument.
- Never import anything from `src/admin/` into request-path code.

Adding a function here needs a second reviewer. Say so in the PR.

The required justification argument is the useful detail. It forces the caller to state why, it lands in the audit log, and it makes an unexplained cross-tenant read impossible to write without deliberately fabricating a reason.

Test the invariant, do not trust it

@pytest.mark.parametrize("endpoint", ALL_TENANT_SCOPED_ENDPOINTS)
def test_cannot_read_other_tenants_resource(client, endpoint):
    a = make_tenant("a"); b = make_tenant("b")
    resource = create_resource(tenant=a)

    r = client.get(endpoint.format(id=resource.id), auth=token(b))

    # 404, not 403 — 403 confirms the resource exists.
    assert r.status_code == 404
    assert str(resource.id) not in r.text

Parametrising over every tenant-scoped endpoint is what makes this durable: a new endpoint added later is covered automatically, including one an agent adds while you are not looking. A test written per endpoint is a test somebody forgets to write.

#!/bin/bash
HITS=$(git diff origin/main...HEAD -U0 | grep -E '^\+' \
  | grep -E 'text\(|execute\(' \
  | grep -Ei 'orders|invoices|documents|attachments|audit_log' || true)

if [ -n "$HITS" ]; then
  echo "Raw SQL against a tenant-scoped table:" >&2
  echo "$HITS" >&2
  echo "Use db/tenant_scope.py, or add 'raw-sql-reviewed' with a reason." >&2
  exit 1
fi
Takeaway

Tenant isolation must hold regardless of what any model writes, so make the unsafe query structurally impossible first — tenant bound from request context, never a parameter, ideally enforced by row-level security. Then write the instructions as a second layer. Watch cache keys, background jobs and error messages, and parametrise the cross-tenant test over every endpoint so new ones are covered automatically.

Keep reading
Codex vs Claude

More Tests or Better-Chosen Tests?

Codex may produce broad test scaffolding when acceptance is explicit. Claude Code may write a smaller set around the immediate bug. Neither density nor.

Codex vs Claude

Local Execution: It Worked in the Session Is the New Works on My Machine

The correctness half of running an agent in your own shell: undeclared toolchains, aliases that rewrite commands, interactive prompts with no answer, and accumulated session state. What to declare in the repository and what to leave ambient.

Codex vs Claude

Below the Prompt: What a Kernel Sandbox Actually Constrains

Seatbelt, bwrap and seccomp enforce policy that injected text cannot argue with. What a profile can express, why a workspace-write policy still permits everything inside your repository including .git/hooks, and a self-test that proves the policy is on.

Codex vs Claude

Default-Deny Egress: The Control You Turn Off in the First Hour

Outbound network access is the highest-value restriction on an agent and the one that breaks npm install. Pre-seeding dependencies, hostname filtering at a proxy, why a credential fits in a query string, and verifying the denial by IP.

← Gating Irreversible Actions: Controls That Don't Depend on the Model  ·  Hardening the Local Toolchain: Prompt Injection on a Developer Machine →

All security engineering articles  ·  Every article