Row-level isolation, schema-per-tenant and database-per-tenant compared, and how to structurally prevent cross-tenant access
The most expensive mistake in a multi-tenant API is not a missing feature, it is a single forgotten WHERE clause that hands one tenant's data to another. This article compares the three established isolation models, row-level isolation, schema-per-tenant, and database-per-tenant, weighs whether the tenant ID belongs in a JWT, a header, or the URL, and describes how to structurally prevent cross-tenant access in a Symfony project instead of relying on every individual developer's discipline.
Table of Contents
- 1. Why tenant isolation determines trust in a SaaS product
- 2. Comparing row-level isolation, schema-per-tenant, and database-per-tenant
- 3. Tenant ID in a JWT, a header, or the URL: where does it belong?
- 4. Symfony Doctrine filters for automatic tenant scope injection
- 5. Practical example: a complete tenant filter activated per request
- 6. Typical gaps even a Doctrine filter does not close automatically
- 7. Actively testing tenant isolation instead of just hoping
- 8. Decision guide: which isolation model fits which project
- 9. Checklist for designing a new tenant-aware endpoint group
- 10. Summary
- 11. FAQ
1. Why tenant isolation determines trust in a SaaS product
In a multi-tenant application, several customers share the same infrastructure, the same application code, and often the same database, even though every customer assumes their data stays invisible to everyone else. That expectation is not a minor detail, it is the very core of the trust a SaaS provider sells in the first place: the moment a single documented case surfaces where customer A saw customer B's data, the reputational damage is usually worse than a classic data loss incident, because it calls into question whether the architecture is fit for production use at all.
The tricky part about cross-tenant data leaks is that they almost never come from a targeted attack, they come from perfectly ordinary programming mistakes: a query written without a tenant filter under time pressure, a new endpoint where the filter was simply forgotten, or a caching layer that reuses responses across tenant boundaries. That is exactly why it is not enough to document tenant isolation as a convention every developer is supposed to remember by hand on every new query. It has to be anchored structurally in the framework, so that a forgotten filter can never result in a delivered response in the first place.
2. Comparing row-level isolation, schema-per-tenant, and database-per-tenant
With row-level isolation, all tenants share the same database and the same tables, every row additionally carries a tenant_id column, and every query has to account for that column in its WHERE clause. This model is the cheapest to operate, because a single database schema serves any number of tenants, migrations only need to run once, and backups can be managed centrally. The downside sits exactly in that simplicity: isolation lives entirely in application logic, and a single forgotten filter immediately causes a data leak, since the database itself knows no barrier between tenants at all, unless you additionally use database-native mechanisms like row-level security.
Schema-per-tenant creates a separate database schema for every tenant within the same physical database instance, which establishes a stronger isolation boundary, because a SQL mistake in application logic ideally runs against the wrong schema and therefore fails visibly instead of silently returning wrong data. The price is operational complexity: migrations have to run against every schema individually, which noticeably costs time once you reach hundreds of tenants, and connection pools have to dynamically pick the right schema per request. Database-per-tenant goes one step further and gives every tenant a completely separate database, sometimes even on dedicated infrastructure, which offers the strongest isolation and the simplest way to satisfy regulatory requirements, but noticeably increases scaling effort, cost, and operational overhead once you reach several thousand tenants.
3. Tenant ID in a JWT, a header, or the URL: where does it belong?
Where the tenant ID lives in a request largely determines how easy it is to forge. If the tenant ID sits as a claim inside a signed JWT issued by the authentication server at login, a client cannot tamper with it without breaking the signature, which makes this the most secure option. Middleware, or a Symfony EventSubscriber, reads the claim directly from the validated token and sets the current tenant context from it before any business logic runs, so the tenant context never originates from unvalidated user input.
A tenant ID placed directly in the URL, say /api/tenants/{tenantId}/orders, is transparent and pleasant for debugging, but it demands an additional, consistently enforced check that the authenticated user actually belongs to exactly that tenant, otherwise an attacker can simply swap the ID in the URL and reach someone else's data. A header like X-Tenant-ID has the same problem unless it is also verified against the authenticated user. In practice a combined model works best: the tenant ID in the JWT is the only trusted source, while a tenant ID in the URL, if present, only serves as an additional check matched against the JWT, never as the sole basis for filtering data.
4. Symfony Doctrine filters for automatic tenant scope injection
The most effective structural protection against forgotten WHERE clauses in a Symfony project is a Doctrine SQL filter that automatically adds a tenant condition to every generated query, regardless of whether the developer even thought about multi-tenancy while writing that query. Doctrine offers a native filter API for exactly this: a filter class extends SQLFilter, implements the method addFilterConstraint, and gets enabled globally, so every query against an entity marked with the filter automatically receives the extra SQL condition, without any developer having to manually pass a tenant parameter through every single repository.
The key advantage of this approach is that isolation no longer depends on every individual developer's discipline, it depends on the configuration of a single, centrally tested filter class. A new endpoint, a new repository, or a new query a developer writes under time pressure is automatically protected, as long as the affected entity is correctly marked with the filter attribute. The example below shows such a filter class together with an EventSubscriber that reads the tenant context from the validated JWT and activates the filter for every request with the correct parameter.
5. Practical example: a complete tenant filter activated per request
In practice the implementation consists of three parts: the filter class itself that generates the SQL condition, an attribute or interface marking every tenant-aware entity, and an EventSubscriber that reads the current tenant from the authenticated JWT on every request and parameterizes the filter accordingly. It matters to activate the filter as early as possible in the request lifecycle, before any controller runs, so that even a repository accidentally called from a service or a console command class stays protected.
Equally important is a deliberate fail-closed strategy: if the tenant context cannot be determined for any reason, say an internal cron job running without an authenticated user, the application should deny access to tenant-aware data rather than accidentally returning every tenant's data unfiltered. The code below shows this setup in a simplified but production-representative form.
<?php
declare(strict_types=1);
namespace App\Doctrine\Filter;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
/**
* Automatically adds a WHERE condition on the current tenant ID to every
* query against a tenant-aware entity, regardless of whether the calling
* code explicitly filters the repository.
*/
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!in_array(TenantAwareInterface::class, $targetEntity->getReflectionClass()->getInterfaceNames(), true)) {
return '';
}
$tenantId = $this->getParameter('tenantId');
if ($tenantId === '') {
// Fail closed: return no rows without a valid tenant context.
return sprintf('%s.tenant_id = -1', $targetTableAlias);
}
return sprintf('%s.tenant_id = %s', $targetTableAlias, $tenantId);
}
}
// EventSubscriber that activates the filter per request:
namespace App\EventSubscriber;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Activates the TenantFilter on every request with the tenant ID taken
* from the validated JWT.
*/
final class TenantFilterSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TenantContext $tenantContext,
) {
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => 'onKernelRequest'];
}
public function onKernelRequest(RequestEvent $event): void
{
$filter = $this->entityManager->getFilters()->enable('tenant_filter');
$filter->setParameter('tenantId', $this->tenantContext->getCurrentTenantId());
}
}
6. Typical gaps even a Doctrine filter does not close automatically
A global Doctrine filter reliably protects every standard query that runs through the entity manager, but not automatically native SQL queries, direct DBAL calls, or bulk operations like DELETE or UPDATE statements that some teams write around the ORM for performance reasons. Every one of these exceptions has to be given the same tenant condition manually, which is why it pays off to specifically flag native queries during code review and capture them in a dedicated PHPStan rule set that reports native SQL calls without a discernible tenant condition as an error.
Another common weak point is caching: if responses are cached at the HTTP level or the query level, the cache key must include the tenant ID, otherwise a reverse proxy or a result cache can accidentally serve one tenant's response to another, even when the underlying database query was filtered correctly. The same applies to asynchronous processing through message queues: a message written to a queue without a tenant context cannot be reliably attributed to a tenant anymore once it is processed in a worker process, which is why the tenant ID should always be carried explicitly as part of the message payload, instead of being implicitly derived from a request context that no longer exists by the time the message is processed.
7. Actively testing tenant isolation instead of just hoping
Because an isolation bug only becomes visible when actual data access happens, and a normal test suite can easily miss it, a dedicated testing approach pays off: for every endpoint returning tenant-aware data, an automated test deliberately creates records for two different tenants and then checks that a request authenticated as tenant A never contains even a single ID belonging to tenant B in its response. This test can be parameterized generically for every tenant-aware entity and run automatically for every new endpoint, as long as the entity is correctly marked with the corresponding interface.
It also helps to add a fuzzing style approach at the API level: a test script systematically iterates over every path parameter that looks like an ID (say, /api/orders/{id}), substitutes IDs belonging to a different tenant, and consistently expects a 403 or 404 status code instead of a 200 carrying someone else's data. This test catches exactly the cases a pure repository unit test misses, namely authorization gaps that only arise at the controller level, for example because a developer loads an entity by ID without first checking whether it even belongs to the current tenant.
8. Decision guide: which isolation model fits which project
For most SaaS products with potentially thousands of small to medium sized tenants, row-level isolation combined with a consistently enforced Doctrine filter is the most pragmatic starting point, because it scales operationally the cheapest and keeps migrations and backups centralized. Once individual tenants grow significantly larger, or regulatory requirements bring physically separated data storage into play, say for customers in finance or healthcare, it becomes worth moving to schema-per-tenant or even database-per-tenant for exactly those large customers, while smaller tenants can stay in the row-level model.
This hybrid strategy, where the isolation model is chosen per tenant rather than globally for the whole platform, shows up in practice more often than a purely textbook example would suggest, because it lets you keep operational simplicity for the majority of tenants while applying stronger isolation exactly where it is contractually or regulatorily required. It matters to make this decision early, because migrating an already live large customer from row-level isolation to schema-per-tenant after the fact is considerably more work than designing the model to be flexible enough from the start.
9. Checklist for designing a new tenant-aware endpoint group
Before a new endpoint serving tenant-aware data goes into production, a short, fixed checklist pays off: does the affected entity carry the tenant interface and is it therefore covered by the global filter? Does the tenant context come exclusively from the validated JWT, and never from an unchecked URL or header value? Does this endpoint involve any native SQL queries or bulk operations, and if so, were they given the tenant condition manually? Does any cache key in use include the tenant ID? And finally, is there an automated cross-tenant test for exactly this endpoint that deliberately tries to access the data with the wrong tenant?
This checklist does not replace a deeper architectural decision, but it catches the most common and most expensive mistakes before they reach production. In practice it is worth baking these points directly into a pull request template, so every reviewer has to explicitly address them for every new endpoint, instead of implicitly relying on the original author's carefulness. Multi-tenant isolation is ultimately not a one-time architecture project, it is a continuous discipline that has to be enforced again with every new line of code.
| Isolation model | Operating cost | Isolation strength | Typical use case |
|---|---|---|---|
| Row-level isolation | Low, one schema for all tenants | Depends on consistent filtering | SaaS with many small to medium tenants |
| Schema-per-tenant | Medium, migrations needed per schema | High, SQL mistakes fail visibly | B2B SaaS with medium sized tenants |
| Database-per-tenant | High, dedicated infrastructure possible per tenant | Very high, physical separation | Regulated industries, very large single customers |
| Hybrid (row-level plus exceptions) | Low for the majority, higher for exceptions | Variable per tenant | Platforms with a few very large key accounts |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
Multi-Tenant REST APIs: The Key Points at a Glance
Row-level isolation
Cheap to operate, but entirely dependent on consistent filtering in every single query.
JWT as tenant source
The tenant ID belongs in a signed JWT, never in an unchecked URL or header.
Doctrine filter
A global SQLFilter injects the tenant condition automatically, independent of any individual developer's discipline.
Cross-tenant tests
Automated tests using two tenants surface isolation bugs before they reach production.