Short answer: for most B2B products, run one application and one PostgreSQL database with a tenant ID on every tenant-owned row. Resolve the tenant once per request from the URL and verify it against the user’s membership. Enforce scoping in a single data-access layer, with row-level security underneath as a safety net. Carry the tenant ID into every job, cache key, file path and log line. Keep a tenant directory that says where each tenant lives, so that a large customer can be moved to a dedicated database later. Prove all of it with automated tests that try to cross the boundary.
The three tenancy models (shared schema, schema per tenant, database per tenant) and how they compare are covered in our overview of SaaS stack and architecture decisions. This article assumes you have read that comparison, or made the choice, and goes into what comes next.
Isolation is decided layer by layer
“Shared or dedicated” is usually discussed as one database decision. In a running system it is a separate decision for every layer that touches customer data, and the layers do not have to match. Cloud vendors sometimes call the fully shared arrangement a pool, the fully dedicated one a silo, and a mixture a bridge. Most real products are bridges.
| Layer | Shared, logically separated | Dedicated per tenant | Sensible starting point |
|---|---|---|---|
| Application processes | All tenants served by the same processes | A deployment per tenant | Shared |
| Database rows | Tenant ID column, scoping in code, optional row-level security | Schema or database per tenant | Shared, with a directory that allows exceptions |
| File storage | One bucket, tenant-prefixed paths, signed URLs | Bucket per tenant | Shared with prefixes |
| Cache | Tenant ID as part of every key | Separate cache instance | Shared with keyed entries |
| Job queue and workers | One queue, tenant ID in every job, per-tenant concurrency caps | Separate queue or worker pool | Shared, with a separate pool for heavy jobs |
| Search index | Tenant filter applied to every query | Index per tenant | Shared with a mandatory filter |
| Encryption keys | One key for the platform | Key per tenant, possibly customer-managed | Platform key; per-tenant keys when a contract asks |
| Network | Public endpoints for everyone | Private connectivity or IP allow-lists per tenant | Public, with allow-lists as a plan feature |
Two practical consequences. First, a leak can happen at any row of this table, and the database is not the most common place: shared caches, file URLs and background jobs are where scoping gets forgotten. Second, when a customer asks for “our own instance”, ask which rows they care about. Often the answer is the database and the encryption key, which is far cheaper to provide than a separate deployment of everything.
Tenant resolution: knowing whose request this is
Every request must be attached to exactly one tenant before any data is touched. There are four common ways to carry that information.
| Method | Example | Strengths | Watch for |
|---|---|---|---|
| Subdomain | acme.yourapp.com | Recognisable address; natural step toward custom domains; browser storage separated by origin | Wildcard DNS and certificates; reserved names (www, api, admin, status); session cookies set on the parent domain are sent to every subdomain |
| Path | yourapp.com/acme/… | Simplest hosting and local development; one origin, one certificate | Every route carries the prefix; tenant slugs must not collide with application routes |
| Token claim or header | API key or access token bound to a tenant | Right for APIs and integrations; nothing to parse from the URL | A client-supplied header on its own is a request, not proof. The tenant must come from the verified credential |
| Custom domain | portal.acme.com | White-label products, customer-facing portals | Domain ownership verification, automated certificates, a lookup from hostname to tenant, and a process for domains that lapse |
Rules that hold whichever method you pick
- Resolve the tenant once, in middleware, and place it in a request context that the data layer reads. Feature code should never parse hostnames or decide scoping for itself.
- Treat the identifier in the URL as a claim. After authentication, check that the user holds a membership in that tenant, and answer with “not found” when they do not.
- Keep the tenant in the URL for browser sessions. If the active workspace lives only in the session, a user with two workspaces open in two tabs will write data into the wrong one sooner or later.
- Bind API keys to one tenant at creation and store them hashed. A key that works across tenants is a support convenience that becomes an incident.
- Give background jobs, webhooks and scheduled tasks the same treatment: each one carries a tenant ID and sets the same context before it runs. Jobs that span tenants, such as nightly billing syncs, loop over tenants and set the context for each iteration.
- Make the tenant slug changeable without breaking anything, by keying everything internal on an immutable ID and treating the slug as a label.
Row-level security in PostgreSQL: one enforcement option
Application-level scoping, where a data-access layer adds the tenant filter to every query, is the primary control. PostgreSQL’s row-level security (RLS) can sit underneath it so that a query which forgets the filter returns nothing instead of returning everything. It is worth understanding precisely, because a half-configured policy gives confidence without protection.
How it works
- Enable it per table with
ALTER TABLE … ENABLE ROW LEVEL SECURITY. Once enabled, rows are invisible unless a policy allows them. - Create a policy that compares the row’s tenant to a per-transaction setting, for example
USING (tenant_id = current_setting('app.tenant_id', true)::uuid). AUSINGclause governs which rows can be read, updated or deleted; aWITH CHECKclause governs which rows may be written. When onlyUSINGis given, it is applied to writes as well. - At the start of each request, open a transaction and set the value with
SET LOCALorset_config('app.tenant_id', $1, true). The setting disappears when the transaction ends. - With the second argument of
current_settingset to true, a missing value yields null, the comparison fails, and the query returns no rows. The system fails closed.
Where it goes wrong
| Pitfall | What happens | What to do |
|---|---|---|
| The application connects as the table owner | Owners bypass policies by default, so RLS silently does nothing | Run migrations as one role and the application as another, non-owner role; or use FORCE ROW LEVEL SECURITY |
Superuser or a role with BYPASSRLS | Policies are never applied | Reserve such roles for maintenance, never for the web application |
Session-level SET behind a transaction-mode connection pooler | The setting stays on the pooled connection and is inherited by the next request, possibly another tenant’s | Always use transaction-scoped settings inside an explicit transaction |
| Views | A view normally runs with its owner’s rights, which can sidestep the policy | Use the security-invoker option available in recent PostgreSQL versions, or avoid views over tenant tables |
| Unique constraints without the tenant ID | A “value already exists” error reveals that another tenant has that value | Make uniqueness composite: (tenant_id, email), not (email) |
| Missing indexes | The policy is an extra filter on every query; without an index that leads with tenant_id, large tables slow down | Put tenant_id first in composite indexes on tenant-owned tables |
| Managed backends that expose the database to the browser | On Supabase-style platforms, RLS is the primary control, not a backup, and one permissive policy exposes a table | Review policies as carefully as you would review an API; test them with real client credentials |
RLS also has a cost in day-to-day work. Legitimate cross-tenant operations (platform analytics, billing reconciliation, support tooling) need a deliberately separate role and code path. Debugging gets one step harder because an empty result may be a policy, not a bug. Some ORMs make per-request transactions awkward. None of this is a reason to avoid it; it is a reason to decide on it at the start, since retrofitting a transaction-per-request pattern into a mature codebase is slow.
Add a check that RLS is on. A short test that queries the PostgreSQL catalog and fails the build if any table with a tenant_id column lacks an enabled policy costs an hour to write. Without it, the table added next spring will be the one that is unprotected.
Per-tenant configuration and feature flags
Three things get mixed together under “settings”, and separating them early keeps the code readable.
| Kind | Who controls it | Examples | Where it lives |
|---|---|---|---|
| Entitlements | The plan the customer pays for, plus negotiated overrides | Seat limit, storage quota, access to the API, SSO | Plan definition in configuration; overrides in a per-tenant table |
| Feature flags | Your team, for release control | New editor enabled for five pilot tenants | Flag table or flag service, evaluated with the tenant as the subject |
| Preferences | The customer’s administrators | Time zone, branding, default roles, notification rules, password policy | Typed columns for the few that matter everywhere; a key-value table with validated JSON for the rest |
Resolve values in layers with one function: platform default, then plan, then tenant override. Every feature asks that function and nothing else. When pricing changes or a salesperson promises a customer a higher limit, the change is a row, not a deployment.
- Avoid tenant names in code. A condition that checks for one specific customer is a fork of your product hiding in an if-statement. Turn it into a named flag or setting that any tenant could have.
- Cache configuration per tenant with the tenant ID in the key, and invalidate on write. Configuration is read on nearly every request.
- Retire flags. A flag that has been on for everyone for three months is dead code with a database lookup attached.
- Treat tenant-supplied secrets differently. API keys for the customer’s own integrations are stored encrypted, shown once, never returned by the settings endpoint and never written to logs.
- Log configuration changes in the tenant’s audit trail with who changed what. “Who switched off two-factor enforcement?” is a question you will be asked.
Migrations across tenants
How painful schema changes are depends directly on the tenancy model, and it is the operational cost most often underestimated when someone proposes a schema or database per tenant.
| Model | What a schema change involves | Main risk |
|---|---|---|
| Shared schema | One migration, run once | Tables hold every tenant’s rows, so they are large; a careless change locks the table for all customers at once |
| Schema per tenant | The same migration executed once per tenant, by a runner that records each tenant’s version | Partial failure: some tenants migrated, some not, and the application must work with both until the run completes |
| Database per tenant | As above, plus connection management and orchestration across servers | Drift: tenants stranded on old versions, each one a special case in support |
Practices that apply to all three
- Expand, then contract. Add the new column or table, deploy code that writes to both old and new, backfill, switch reads, and remove the old structure in a later release. Every step is compatible with the application version before and after it, which is what makes partial progress survivable.
- Backfill in batches, tenant by tenant, as a background job with progress tracking and the ability to pause. One transaction that rewrites a hundred million rows is an outage.
- Know which operations take heavy locks on your database version, and use the online alternatives: building indexes concurrently, adding constraints as not-valid and validating afterwards.
- Set a lock timeout on migration sessions so that a migration queued behind a long-running query fails fast instead of blocking every request behind it.
Additional rules for schema or database per tenant
- Keep a version record per tenant, and have the runner be resumable and safe to run twice.
- Migrate a small group of internal and low-risk tenants first, watch error rates, then proceed in waves.
- Decide in advance what happens when tenant 412 of 900 fails: the runner continues, the failure is reported, and the application tolerates the older version for that tenant.
- Measure the total run time. When it grows from minutes to hours, your release process has changed whether you planned it or not.
- Provision new tenants from the current schema, not by replaying every migration since the beginning.
Noisy neighbours and rate limiting
In a shared system one customer’s bulk import, runaway integration or unusually large account can degrade the service for everyone. Customers do not accept “another customer caused it” as an explanation. Controls belong at several points.
| Where | Control |
|---|---|
| API edge | Rate limits keyed by tenant as well as by user and IP address, using a token-bucket or sliding-window counter. Return a clear “too many requests” response with a retry time. Make limits an entitlement so that higher plans can have higher ceilings |
| Expensive endpoints | Separate, lower limits for search, reports, exports and anything that calls a paid third-party API or a language model |
| Job queue | A cap on concurrent jobs per tenant, and fair scheduling so that one tenant’s 50,000 queued jobs do not sit in front of everyone else’s single job. Route heavy work such as imports and report generation to its own worker pool |
| Database | Statement timeouts for web requests; mandatory pagination with a maximum page size; reports and analytics served from a read replica; a connection pool sized so that one slow path cannot exhaust it |
| Storage and outbound traffic | Quotas per tenant for file storage, email sends and webhook deliveries, tied to the plan |
| Observability | Tenant ID on every metric, log line and trace, with a dashboard of top tenants by requests, query time, queue usage and errors |
The last row is the one that makes the others usable. Without per-tenant metrics you will know the system is slow and not know why. With them, the usual finding is that one or two tenants account for a large share of the load, and the conversation can move to limits, a plan upgrade or a dedicated instance.
Backups and restores per tenant
A backup of a shared database protects the platform against disaster. It does not, by itself, answer the request you will actually receive: “an administrator on our side deleted a project yesterday; can you bring it back?” Rolling the whole database back would destroy every other customer’s work since then.
A workable procedure for the shared model
- Restore a point-in-time copy of the database to a separate, temporary instance.
- Extract the affected tenant’s rows using the tenant export routine described in the next section.
- Compare with production and merge back the missing records, under review, inside a transaction, preserving IDs so that references and file links still resolve.
- Record what was restored in the tenant’s audit log and tell the customer exactly what was and was not recovered.
- Destroy the temporary instance.
Rehearse this before a customer needs it, and time it. Backup retention sets how far back you can go, so state that window in your terms.
Reduce how often you need it
- Soft-delete significant objects, with a trash view and an undo period, and purge on a schedule
- Keep version history for the objects customers edit most
- Turn on object versioning in file storage, since a database restore does not bring back deleted files
- Offer scheduled exports to customers who want their own copy
With a schema or database per tenant, single-tenant restore is a standard database operation, and that is one of the real advantages of those models. It is rarely sufficient on its own to justify their running cost.
Data export and deletion
Customers ask for their data when they leave, auditors ask how deletion works, and data-protection law in many of your customers’ jurisdictions gives individuals and business customers rights over both. One mechanism serves export, deletion, per-tenant restore and tenant moves, so it is worth building properly once.
- A tenant data registry: a list in code of every tenant-owned table, its tenant column, and its dependencies, plus every non-database store (files, search index, cache, analytics events).
- A guard test that fails the build when a table with a tenant ID is missing from the registry. This is what keeps the mechanism complete as the schema grows.
- Export as a background job that walks the registry and produces documented, machine-readable files plus the tenant’s uploaded files, delivered through an expiring link to a verified administrator.
- Deletion in stages: the account is closed and access ends; a grace period follows in which the decision can be reversed; then a hard delete runs in batches in dependency order and covers files, search documents, caches and integrations’ stored credentials.
- What cannot be deleted at once, stated openly: backups age out on their retention schedule; invoices and tax records are kept as long as the law requires; security logs may be retained for a fixed period.
- Sub-processors: the deletion job also calls the email, analytics and support tools that hold the tenant’s data, or the process document says who does that manually.
- A deletion record that keeps the tenant ID, the date and the scope, and no customer content, so that you can show it happened.
Not legal advice. Retention periods and deletion duties differ between jurisdictions and contracts. Agree the policy with whoever is responsible for privacy in your company, then make the code follow the policy.
When to move a large customer to a dedicated instance
The hybrid arrangement, in which most tenants share and a few have their own database or deployment, is where many successful products end up. The enabling decision is small and should be made at the start: a tenant directory, a table that maps each tenant to its database connection, region and deployment, consulted once per request and cached. While every tenant points to the same place, it costs nothing.
| Signal | Try first | Move when |
|---|---|---|
| One tenant dominates database load | Per-tenant limits, query and index work, heavy reports on a replica | Load still affects others, or the tenant needs capacity that the shared tier cannot give economically |
| Contract requires physical separation | Clarify which layers the clause means; offer database and key separation | The customer confirms it and the price covers it |
| Data residency in a region you do not serve | Nothing in the shared deployment can satisfy this | There is signed demand; then a regional deployment can host that tenant and later ones |
| Customer-managed keys, private networking, fixed IP addresses | Check whether per-tenant keys in the shared tier are acceptable | The requirement is firm |
| Customer wants to control upgrade timing | Feature flags and a preview environment | Rarely. A pinned version is a fork you maintain indefinitely; price it to reflect that, or decline |
What a move involves
- Provision the target and bring it to the current schema version.
- Copy the tenant’s data with the export routine, in a rehearsal run, and verify row counts and checksums per table.
- Schedule a short write freeze for that tenant, copy the final changes, and verify again.
- Update the tenant directory so that requests and jobs route to the new location.
- Keep the old rows read-only for an agreed period, then delete them through the normal deletion job.
Count the continuing costs before offering this: every dedicated instance joins the migration run, the monitoring, the backup schedule and the on-call scope. Dedicated infrastructure belongs on a plan whose price reflects it. How these choices affect a build budget is covered in our breakdown of SaaS development costs.
Testing for cross-tenant leaks
Isolation that is not tested decays. Each new endpoint, job and report is a new chance to forget the scope, and code review does not catch all of them. The tests below are inexpensive next to a single disclosure incident.
Automated tests in the build pipeline
- Two-tenant fixture. Every test run creates tenants A and B with similar data. Tests authenticate as a user of A.
- ID-swap tests for every route. Request, update and delete B’s objects using A’s credentials. Expect “not found”, not “forbidden”, so that the response does not confirm the object exists. Generate these tests from the route list so that a new endpoint is covered without anyone remembering to write one.
- List, search and aggregate endpoints. Assert that results and counts contain only A’s data. Totals and autocomplete suggestions leak as readily as records.
- Nested references. Create an object in A that points to a parent ID belonging to B. The write must be rejected.
- Files. A signed URL or storage path from B must not be retrievable with A’s session, and URLs must not be guessable.
- Jobs, exports, emails and webhooks. Run them for A and assert that nothing from B appears in the output or the recipient list.
- Real-time channels. A client subscribed as A must be refused B’s channels.
- Cache. Request the same resource as A and then as B and confirm that B never receives A’s cached response.
Structural checks
- A schema test: every table either has a non-null tenant ID or appears on a short, reviewed list of global tables
- The catalog check that row-level security is enabled where you rely on it
- A lint rule or code-review rule that forbids database access outside the scoped data layer, with the exceptions listed by name
- A cache helper that requires a tenant argument, so that an unscoped key cannot be constructed by accident
Checks in production
- An assertion in the response serialiser that compares each object’s tenant ID with the request’s tenant, blocks the response on mismatch and raises an alert
- Support access through explicit, time-limited impersonation that is recorded in the tenant’s audit log, never through a shared administrator account
- An independent penetration test, with cross-tenant access stated as a primary objective, before you sell to buyers who will ask for the report
A design-review checklist
- Tenant resolved once per request, verified against membership, visible in the URL
- Immutable tenant ID on every tenant-owned row, first in composite indexes and unique constraints
- One scoped data-access layer; row-level security underneath if the stack allows a transaction per request
- Tenant ID in every job, cache key, file path, search document, metric and log line
- Entitlements, flags and preferences resolved by one function, with no customer names in code
- Expand-and-contract migrations; batch backfills; a lock timeout
- Per-tenant rate limits, job concurrency caps and a top-tenants dashboard
- Tenant data registry powering export, deletion, single-tenant restore and moves, protected by a guard test
- Tenant directory in place, even while it points everyone at one database
- Generated cross-tenant tests in the pipeline, and a runtime mismatch alert
Most of this is a few extra days at the start of a build and several weeks if added later, which is why tenancy belongs in the first architecture conversation and not the tenth. The order in which to build the rest of the product is set out in our guide to taking a SaaS product from idea to paying customers. If you are evaluating vendors, how to choose a SaaS development company turns the points above into interview questions. And if you would like a team that works this way on TypeScript, Node.js and PostgreSQL, see BBR’s SaaS development service.
