Multi-tenancy is not simply an additional tenant_id column. An application must stop data, actions, files and background jobs from crossing customer boundaries. At the same time, every object has business permissions of its own. Combining both rules creates security logic that is difficult to verify.
Three separate questions
Every access decision needs at least three answers:
- Which tenant owns the current request context?
- May the authenticated person use this specific object?
- Is the requested action allowed in the current business state?
A user may belong to the correct tenant without being allowed to approve every invoice. A person with approval rights may still be unable to post a cancelled invoice. These rules need separate names, tests and failure cases.
Do not trust tenant context from user input
A tenant ID from a route, header or form is untrusted input. The server must verify it against session, token, domain or another reliable relationship. It can then establish an explicit context for the request.
Global Eloquent scopes reduce accidental broad queries, but they are not a complete security model. Queries through DB::table() and Eloquent queries that explicitly call withoutGlobalScope() bypass them. Administrative processes and queued jobs still apply model scopes in principle, but often have no request tenant set automatically; establish that context explicitly. The boundary must remain visible in repositories, policies and database access.
Express object permissions in policies
Laravel policies give actions such as view, update, delete and approve a central place. A policy should check the established tenant context and the user’s relationship to the object. Controllers and interfaces may call this decision, but should not rebuild competing variants.
This global role check is too weak:
return $user->role === 'manager';
The actual question is whether the user is a manager in this tenant, the object belongs there and its state allows the action. Code that mirrors the product question is easier to test.
Review hidden boundaries
Many data leaks occur outside normal controllers:
- queue jobs lose tenant context or load an object by ID alone,
- signed download links verify a signature but not current access,
- exports use an overly broad query,
- search indexes contain multiple tenants and filter only in the UI,
- cache keys omit tenant or role,
- logs and errors include another customer’s data.
Every asynchronous process must serialise context explicitly and validate it again during execution. A job must not rely on global state from the original request.
Use negative tests as evidence
The important test is not only “tenant A can open its invoice”. Also test:
| Test | Expected outcome |
|---|---|
| user A requests an ID from tenant B | 404 or controlled rejection |
| manager without object relationship approves | forbidden |
| job receives an object from another tenant | abort and alert |
| signed link used after access was revoked | no access |
| search contains a foreign object | never returned |
Factories for at least two tenants make these cases readable. Random IDs are insufficient when relationships need to be crossed deliberately.
Use the database as another boundary
Unique constraints should include the tenant where values only need to be unique within one customer. Foreign keys and constraints reject invalid relationships even if application code fails. Database row-level security can add protection for high-risk systems, but increases operational and testing complexity.
The architecture depends on protection needs, team and platform. Separate databases provide strong isolation while making migrations, reporting and operations harder. A shared database can be dependable when context and testing are consistent.
A compact cross-tenant matrix in Pest
The matrix should submit the same foreign object ID through every reachable path, not only the controller:
it('rejects cross-tenant access through every delivery path', function (string $path) {
$user = User::factory()->for($tenantA)->create();
$invoice = Invoice::factory()->for($tenantB)->create();
actingAs($user);
match ($path) {
'http' => getJson(route('invoices.show', $invoice))->assertForbidden(),
'download' => get(route('invoices.download', $invoice))->assertForbidden(),
'export' => postJson(route('exports.store'), ['invoice_id' => $invoice->id])
->assertForbidden(),
};
})->with(['http', 'download', 'export']);
A separate queue test dispatches a job with identifiers from two tenants and expects controlled termination before data access. Whether the application returns 403 or 404 is a deliberate product decision. The important property is that every path enforces the same invariant and no foreign data reaches the response, log or export.
Laravel security starts before the controller explains the broader architecture. 10 common security mistakes in Laravel projects provides concrete unsafe and improved implementation patterns.
Sources and further reading
- Laravel 13: Authorization
- Laravel 13: Eloquent Global Scopes
- OWASP ASVS: Access Control
- OWASP Authorization Cheat Sheet
Conclusion
Secure multi-tenancy uses several visible boundaries. Tenant context, object permission and business state are decided separately and tested together. Queue jobs, downloads, search and caching deserve the same care as controllers.