Multi-tenant SaaS and AI coding agents: a global scope is not as global as it sounds
Ask an agent to add a "usage this month" export for support, and it does the obvious thing: pull every row from the usage table, group by account, write a CSV. It works. You open the file, check the numbers for your own test account, and they match. What you don't check is the row two down: a different company's usage, sitting in the same export, because the query never asked which account a row belonged to. Nothing forced it to ask.
Nobody wrote a bug. The agent built exactly the report it was asked for, against a table that happens to hold every customer's data in one place.
Why a tenant-scoping miss is invisible to an agent
A working query and a correctly scoped query produce the same result in almost every session: one account, one seeded dataset, one developer looking at their own data. The row count is right. The test, if there is one, asserts on the shape of the response, not on whose data is in it. "Correct" and "correctly scoped" only diverge once two tenants' rows land in the same result set, and a fresh Claude Code, Codex or Gemini session building a feature against a fixture with one seeded company has no way to notice that divergence exists.
This is a narrower, sharper version of a miss this blog has already named: authorization that checks who you are and stops there. The tenant version is easier to write by accident: the query usually is scoped correctly, to a model, just not to the account that model belongs to. It's the same blind spot a migration backfill runs into when it updates every row in one pass: the mechanism knows which rows match the query, not which tenant each one belongs to.
The scope that isn't actually global
Laravel's own name for the fix invites the mistake. A global
scope attaches a
constraint to a model with one attribute, #[ScopedBy], or one call in
booted(), and every query for that model carries it from then on. It
reads like protection that, once added, covers the app. It covers one
model.
Three gaps sit inside that single word "global":
- It's per model, not per app. Add
TenantScopetoInvoiceandInvoice::all()is safe. TheMonthlyReportmodel an agent adds next sprint starts with no scope at all until someone remembers to attach one, and remembering isn't a step anywhere in the brief "add a monthly report." - It doesn't reach
DB::table(). A scope lives on the Eloquent builder for its model. The moment a query drops to the plain query builder, for an awkward join or a hand-written export, the scope isn't skipped. It was never in the request. - It's one call away from gone.
withoutGlobalScopes()exists for legitimate admin tooling, and it's also what an agent reaches for when a scope is "in the way" of a query it can't get working, with no signal it just deleted the tenant boundary along with the actual problem.
None of this is a bug in Laravel. A global scope does precisely what its documentation says. The gap sits between what the name promises and what one attribute on one model actually reaches.
The worker that remembers the wrong tenant
Queued jobs open a second, quieter gap. Laravel's own docs are direct
about it: queue workers are long-lived processes, and "any static state
created or modified by your application will not be automatically reset
between jobs."
A singleton container binding, the usual first instinct for "current
tenant," is exactly that kind of static state. It resolves once and
answers the same way for every job the worker picks up afterward, not
just the one it was bound for.
Laravel ships the actual fix: a
scoped
binding instead of a singleton, flushed at the start of every new
request or job. But scoped is the one you have to choose on purpose.
singleton is what every tutorial reaches for first, and in a web
request or a worker restarted between local test runs, the two behave
identically. They diverge only under the same production load that
makes background jobs run more than
once: a long-lived worker,
processing job after job, tenant after tenant, with no restart in
between.
Where tenant isolation goes wrong, and what it really costs
These bugs are quiet by construction. Nothing throws. The wrong row renders inside a page that otherwise looks correct, to a user with no way to know it isn't theirs.
A real one already happened at MCP-server scale. Asana's MCP integration shipped May 1, 2025. A logic flaw let one organization's project data reach another organization's AI assistant, undiscovered for over a month before Asana pulled the feature offline for two weeks to fix it. Roughly a thousand customers were told to review what their assistants may have ingested from accounts that weren't theirs. Nobody had to attack anything. A boundary that should have applied per organization didn't, in a feature shipped fast and working exactly as tested.
The forensic question is worse than the fix. The code change is
usually one line: add the missing scope, or restore scoped over
singleton. Figuring out how long the gap existed, and which accounts
saw whose data, is the part that takes weeks, and the part regulators
and enterprise customers actually ask about.
It's a contract problem before it's a compliance problem. For a B2B SaaS product, a cross-tenant leak reads as proof the isolation a prospective customer is paying for doesn't hold, closing deals a clean pen-test report was supposed to open.
Read the scope, not just the diff
SanuDesk is a desktop app built around exactly that gap between code
that passed review and code actually checked for whose data it touches.
Your Claude Code, Codex and Gemini sessions tile into one grid, so a new
DB::table() call or a withoutGlobalScopes() sitting in the same diff
as a new feature is something a reviewer reads next to the change, not
something found later in an audit. Work arrives through a Kanban board:
"add the usage export" is a card, and "does every query here still carry
the tenant scope" is a question the review lane can ask before the card
moves to done. Because SanuDesk is bring-your-own-model, that review can
run on a cheaper model than the one that wrote the feature. A recurring
sweep, grepping for raw queries and withoutGlobalScopes calls against
tenant-owned tables, becomes a Loop, journaled per run instead of a
check everyone means to schedule. The grid and board are in the free
plan (see pricing).
Run the query your global scope is supposed to block
Pick your most sensitive tenant-scoped model and run two counts in
php artisan tinker: YourModel::count() against
YourModel::withoutGlobalScopes()->count(). If they match, either your
seed data only has one tenant, which tells you nothing, or nothing is
actually scoping that model yet, which tells you everything. Then grep
the codebase for that model's table name next to DB::table(. Every hit
is a query your scope never protected.
Download SanuDesk free to give every query a reviewer before it ships, or see how the grid, the board and Loops fit together on the features page.