A Multi-Tenant Query You Cannot Forget to Scope
A Multi-Tenant Query You Cannot Forget to Scope

The worst bug in a multi-tenant application is four characters long. It is the WHERE clause that somebody did not write.
Everything else about the feature works. The page loads, the numbers add up, the export downloads. It is simply showing one company a row that belongs to another, and nothing in the system objects, because as far as the database is concerned that is a perfectly good query.
You will not catch it in testing, because in testing there is one organisation and every row belongs to it. A query with no tenant filter and a query with the correct one return exactly the same result.

Why discipline is not a strategy
The obvious answer is that everyone remembers to scope their queries. That works, for a while, for a small team, on the paths people touch often.
It fails in the places nobody is looking. A report written for one customer. An export endpoint added under time pressure. A scheduled job that has no logged-in user and therefore no obvious tenant. A migration that backfills a column. A support script somebody ran once.
Each of those is written by a person who knows about multi-tenancy and is thinking about something else at the time. The rule is remembered nineteen times and forgotten on the twentieth, and the twentieth is the one that matters.
A safety property that depends on nobody ever forgetting is not a safety property. It is a hope with a code review attached.
So the goal is not to remember better. It is to arrange things so that the unscoped query does not compile, does not run, or does not return.
Layer one: a global scope
In Laravel, a global scope attaches a condition to every query on a model, automatically, without the calling code mentioning it.
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($id = app(Tenant::class)->id()) {
$builder->where($model->getTable() . '.organization_id', $id);
}
}
}
abstract class TenantModel extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
// Stamp it on the way in, so nothing is ever created unscoped.
static::creating(function (Model $m) {
$m->organization_id ??= app(Tenant::class)->id();
});
}
}
Now TimeEntry::all() returns this organisation’s time entries, and a developer who writes it without thinking gets the right answer. That is most of the value: the lazy path and the correct path are the same path.
The qualified column name matters. Write where('organization_id', ...) and the first join against another table with that column produces an ambiguous column error, which developers then fix by removing the scope.
Where the global scope does not reach
It is a good first layer and it has holes, and the holes are exactly where the incidents happen.
The query builder bypasses it entirely. DB::table('time_entries') knows nothing about models or scopes. Every raw query is unprotected, and raw queries are what people reach for when a report gets complicated.
Joins are not scoped. TimeEntry::join('projects', ...) scopes the time entries and not the projects. If the join condition is on project_id alone, a crafted id pulls in another organisation’s project row.
Relationships through an unscoped model. If Project has the scope but Task does not, then $project->tasks is fine and Task::find($id) is not.
withoutGlobalScopes() exists and is sometimes genuinely needed — an admin panel, a cross-tenant report. Once it appears in the codebase it gets copied.

Layer two: make the unscoped query fail
A hole you know about can be turned into a loud failure. The trick is to check, at the moment a query runs, that a tenant condition is present — and to throw if it is not.
// Laravel: inspect every query before it goes out, in local and staging.
DB::listen(function (QueryExecuted $q) {
if (!app()->environment(['local', 'staging'])) return;
$sql = strtolower($q->sql);
if (!str_starts_with($sql, 'select')) return;
// Tables that must never be read without a tenant filter
$guarded = ['time_entries', 'screenshots', 'projects', 'tasks', 'invoices'];
foreach ($guarded as $table) {
if (str_contains($sql, " $table") && !str_contains($sql, 'organization_id')) {
throw new RuntimeException("Unscoped query on {$table}: {$q->sql}");
}
}
});
It is a blunt instrument — a string search on SQL — and that is fine, because it is not a security control. It is a development aid that makes the mistake impossible to miss while you are making it, rather than months later.
Keep it out of production. In production it would turn a data-leak bug into a 500 error, which is arguably better but is a decision to take deliberately rather than inherit from a debugging tool.
Layer three: the database itself
The layer that does not care how the query was written is the database. Postgres has row-level security, and it is the only one of these that a raw query cannot bypass.
ALTER TABLE time_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON time_entries
USING (organization_id = current_setting('app.current_org')::bigint);
-- Then, once per request, on the connection:
SET app.current_org = '42';
Now a query with no filter returns this organisation’s rows because the database added the condition, and a query for another organisation’s id returns nothing at all. DB::table(), a raw SELECT, a psql session with the app’s credentials — all covered.
Two caveats worth knowing before reaching for it. MySQL has no equivalent, so on MySQL this layer simply does not exist and the application ones have to carry more weight. And connection pooling is a real hazard: the session variable belongs to the connection, so a pooled connection handed to the next request still carries the previous tenant’s value unless you reset it.
// Reset at the start of every request, not just set it.
DB::statement("SET app.current_org = ?", [$orgId ?? '0']);
The jobs that have no user
Background jobs are where this breaks most often, because the tenant usually comes from the logged-in user and a job has none.
The pattern that fails quietly is a job that reads the tenant from a global that happens to still hold the last request’s value. It works in development, where jobs run synchronously right after a request. It breaks in production, where the worker is a separate long-lived process — and it breaks by processing the wrong organisation’s data rather than by erroring.
Put the tenant in the job payload, explicitly, and set it on entry:
class PruneScreenshots implements ShouldQueue
{
public function __construct(public int $organizationId) {}
public function handle(Tenant $tenant): void
{
$tenant->setId($this->organizationId); // never inferred
// ... now scoped work
}
}
And for a job that genuinely spans every tenant — a nightly prune, a usage rollup — loop over organisations and do each one scoped, rather than writing one unscoped query across all of them. It is slightly slower and it means a bug affects one customer instead of every customer.

The shape of the identifier matters more than it looks
A detail that decides how bad the failure is when one does slip through: whether your primary keys are guessable.
With sequential integers, /invoices/1043 is a valid guess and so is 1044. A missing tenant check on that route is not a theoretical leak; it is one somebody finds by changing a number in the address bar, and they find it by accident while looking for their own invoice.
With a UUID or a ULID, the same missing check is still a bug and it is not discoverable by hand. Nobody guesses 0192f4c1-8e33-7a1b-9c2d-1f5e8a0b7d44.
// Sequential: the id is also a guess
Route::get('/invoices/{invoice}', ...); // /invoices/1043, /invoices/1044
// ULID: sortable, still opaque from outside
$table->ulid('public_id')->unique();
Route::get('/invoices/{invoice:public_id}', ...);
This is defence in depth, not a substitute for scoping. An unguessable id turns a walk-in leak into one that needs a real mistake somewhere else first, and that is worth having. It is also cheap to add early and painful to retrofit once ids are in URLs customers have bookmarked.
The related habit is not returning the internal id in API responses at all. If the JSON carries only the public identifier, a leak of one row does not hand over the numbering scheme for the whole table.
Reports and exports, where it goes wrong most
Almost every real incident in this class comes from the same place, and it is not the CRUD endpoints. It is reporting.
Reports are where someone writes SQL by hand because Eloquent got awkward. They aggregate across several tables, so there are multiple places a filter is needed rather than one. They are often added quickly for one customer who asked. And they are frequently the only code path that touches some of those tables at all, so no other test covers them.
-- The shape that leaks: the filter is on one table, and the join is not
SELECT p.name, SUM(t.minutes) AS total
FROM time_entries t
JOIN projects p ON p.id = t.project_id -- no tenant condition here
WHERE t.organization_id = ? -- only the base table is scoped
GROUP BY p.name;
That query looks scoped and is not. If a time entry ever carries a project_id belonging to another organisation — which it should not, but which a bad import or an old bug can produce — the project name from the other tenant appears in this report.
-- Scope every table you touch, not just the one you started from
JOIN projects p ON p.id = t.project_id AND p.organization_id = t.organization_id
Adding the condition to the join rather than the WHERE is deliberate. It makes the relationship explicit — these two rows belong to the same tenant — and it keeps working when the query later becomes a LEFT JOIN, where a WHERE condition on the joined table would silently turn it back into an inner join.
Finding the queries you already have
All of the above helps new code. The existing codebase is the more urgent question, and it is answerable in an afternoon.
Turn on the query listener from earlier, then exercise the application: click through every page, run every report, trigger every export, run the scheduled jobs by hand. Each unscoped query throws with its SQL in the message, and the list you end up with is the actual risk register.
# A quicker first pass: find the raw builder calls to review by hand
grep -rn "DB::table|DB::select|DB::raw" app/ --include=*.php | wc -l
grep -rn "withoutGlobalScope" app/ --include=*.php
We ran that and found the count was not large, which was reassuring, and that most of them were in exactly the places predicted — reports, one export, and a maintenance command written eighteen months earlier that nobody had opened since.
The second grep is the more interesting one. Every
withoutGlobalScopein a codebase was added for a reason, and about half of them have outlived it.
Testing for the thing that is hard to test
The test that catches this is the one nobody writes, because it requires a second organisation in the fixture.
public function test_a_report_never_returns_another_organisations_rows(): void
{
$mine = Organization::factory()->create();
$theirs = Organization::factory()->create();
TimeEntry::factory()->count(3)->for($mine)->create();
TimeEntry::factory()->count(5)->for($theirs)->create();
$this->actingAsOrganization($mine);
$rows = app(ProjectReport::class)->forMonth('2026-09');
$this->assertCount(3, $rows);
$this->assertEmpty($rows->pluck('organization_id')->diff([$mine->id]));
}
The second assertion is the important one. Counting rows catches the obvious leak; checking that every returned row belongs to the expected organisation catches the join that pulled in one extra.
Make the two-organisation fixture the default in the test base class rather than something each test opts into. A test suite where every test has a neighbouring tenant with data will catch a missing scope the first time somebody writes one — and a suite with one organisation will never catch it at all, however many tests it has.
The one exception worth building deliberately
Every multi-tenant system eventually needs to cross tenants on purpose — a support engineer looking at a customer’s data to answer a ticket, or an internal dashboard counting usage across all organisations.
The temptation is to reach for withoutGlobalScopes() at the call site. Do that once and it is a reasonable exception; do it in four places and the codebase no longer has a rule, it has a convention some code follows.
Build one explicit way through instead, and make it visible when it is used:
// The only sanctioned bypass. Logged, scoped to a block, impossible to leave on.
Tenant::actingAcrossTenants(function () {
return Organization::withCount('users')->get();
}, reason: 'monthly usage rollup');
Two properties make it safe enough to live with. It is a closure, so the bypass ends when the block does rather than leaking into the rest of the request. And it takes a reason, which ends up in the audit log — so “who looked at this customer’s data, and why” has an answer that does not depend on anyone remembering.
What we actually run
All three layers, and they do different jobs.
The global scope makes the correct query the easy one, which handles the ninety per cent of code that is written without thinking about tenancy at all.
The query listener turns the remaining ten per cent into a failure during development, loudly, with the offending SQL in the message.
The test fixture with two organisations means a regression shows up in CI rather than in a support ticket.
What we do not have is row-level security, because we are on MySQL. That is a real gap and it is worth naming rather than glossing over: on MySQL, a raw query written by someone who forgot is not stopped by the database. The application layers are all there is, which is exactly why there are three of them.

