Somanath StudioTalk to an Engineer
Back to Writing
12 min read
SaaS tenant isolationmulti-tenant authorizationPostgreSQL row-level securityproduction readiness

Your SaaS Login Is Not Tenant Isolation: A Founder’s Blueprint

Tenant identity flowing through policy enforcement into isolated SaaS data stores

A user signs in successfully. Their token is valid. Their role says admin.

None of that proves they should be able to read a particular invoice, export, file, or customer record.

That distinction becomes expensive when a SaaS product moves from a few friendly design partners to larger customers. Enterprise buyers will ask how their data is separated. Engineers will add background jobs, support tools, webhooks, and AI features that access the same records through different paths. A single forgotten tenant filter can become a cross-customer data exposure.

The important architectural rule is simple:

Identity tells you who is making a request. Tenant isolation proves which customer boundary applies to that request, every time it touches a protected resource.

AWS made the distinction explicit in its current multi-tenant authorization guidance, published on June 29, 2026: a user can be authenticated and authorized yet still reach another tenant’s resources unless the system applies an explicit isolation mechanism. The guide recommends consistent policy enforcement across APIs rather than scattered, endpoint-specific logic. AWS’s prescriptive guidance is vendor-specific in its implementation options, but the architecture is useful on any stack.

This article turns that architecture into a practical plan for a small SaaS team using a typical web application and relational database.


Authentication, Authorization, and Isolation Are Different Jobs

These three controls answer different questions:

  • Authentication: Who is this user or service?
  • Authorization: May this principal perform this action?
  • Tenant isolation: Does the target resource belong to the tenant context under which the action is allowed?

Imagine a project-management SaaS with two customer organizations: Northstar and Acme.

Priya is an administrator for Northstar. Authentication proves that Priya controls her account. Authorization proves that a Northstar administrator may delete projects. Isolation must still prove that project prj_842 belongs to Northstar before the delete happens.

Checking only role === "admin" misses the customer boundary. Checking only a project ID supplied by the browser trusts input the caller can change. A UUID makes the record harder to guess, but it does not make the caller entitled to it.

This is the same failure class OWASP describes as broken object-level authorization: an API accepts an object identifier without proving the caller may act on that specific object. OWASP recommends an authorization check in every function that uses client input to access a record, plus tests that block deployment when the control regresses. The OWASP API guidance is worth turning into an acceptance criterion, not merely a security-reading bookmark.

Tenant isolation adds a stronger invariant:

requested resource.tenant_id === trusted request context.tenant_id

The difficult words are trusted request context. The browser must not get to declare the tenant for a sensitive operation simply by sending a header, form field, path segment, or GraphQL variable.


Establish One Trusted Tenant Context

Every protected request needs a tenant context derived from a source the server can trust.

For an ordinary interactive request, the flow might be:

  1. Verify the session or signed token.
  2. Resolve the user’s active membership from server-controlled data.
  3. derive tenantId, userId, and relevant attributes.
  4. pass that context through the application as a single object.
  5. reject the request if the tenant cannot be resolved unambiguously.
type RequestContext = {
  userId: string
  tenantId: string
  roles: string[]
  requestId: string
}

If users can belong to multiple organizations, switching organizations should update a server-validated active membership. A route such as /acme/settings can improve navigation, but the acme slug is a lookup hint—not proof of membership.

Machine-to-machine paths need the same discipline. Queue messages, scheduled jobs, webhook deliveries, export workers, support tooling, and agent tool calls should carry an immutable tenant identifier issued by trusted server code. On receipt, the worker must validate that context before loading data.

Do not fall back to a “system tenant” when context is missing. Missing context should fail closed.

OWASP’s authorization guidance recommends least privilege, deny by default, and permission validation on every request. It also warns against relying on client-side access checks or framework defaults. The complete authorization checklist is a good baseline for the shared request-context layer.


Enforce the Boundary at More Than One Layer

The safest practical design uses two complementary controls:

  • an application or API policy check that understands actions and business rules;
  • a data-access boundary that prevents accidental unscoped reads and writes.

Neither layer should be treated as magic. Together they make a developer mistake less likely to become customer-visible.

Put a policy enforcement point at protected entry paths

A policy enforcement point receives the trusted context, action, and resource attributes, then asks a policy decision function whether the action is allowed.

const decision = await authorize({
  principal: context.userId,
  tenant: context.tenantId,
  action: "project:delete",
  resource: {
    type: "project",
    id: project.id,
    tenantId: project.tenantId,
  },
})

if (!decision.allowed) {
  return new Response("Forbidden", { status: 403 })
}

The policy can remain inside a well-tested module in a modest monolith. You do not need to introduce a networked policy service on day one.

External policy engines become useful when rules are reused across many services, customers define custom roles, or audit teams need policy changes separated from application deployments. AWS describes policy decision points as centralized decision makers and policy enforcement points as distributed checks at APIs, services, or Backend-for-Frontend layers. Its implementation guidance recommends a reusable enforcement library so teams do not rewrite the same glue at every endpoint. The PEP pattern is documented here.

The pattern matters more than the product choice:

trusted context + action + resource attributes
                    ↓
              policy decision
                    ↓
             allow or deny only

Keep enforcement boring. A denied or unavailable decision should not silently turn into access.

Scope the data query itself

An API check followed by an unscoped database query still leaves room for mistakes. Prefer repository functions that require tenant context:

async function getProject(context: RequestContext, projectId: string) {
  return db.project.findFirst({
    where: {
      id: projectId,
      tenantId: context.tenantId,
    },
  })
}

Avoid a generic getProject(projectId) helper for tenant-owned data. Its convenient signature makes unsafe use easy.

The same rule applies to updates and deletes. Scope the mutation in the database operation rather than fetching an object, checking it, and then issuing a second unscoped query. That reduces both accidental bypasses and time-of-check/time-of-use problems.

If the database supports row-level policies, use them as another guardrail. PostgreSQL Row-Level Security can restrict which rows a role may select or modify; after RLS is enabled, a table with no applicable policy uses default deny. PostgreSQL also notes important exceptions: table owners normally bypass RLS, roles with BYPASSRLS always do, and table-wide operations such as TRUNCATE are outside row security. The PostgreSQL documentation should be read before treating RLS as a complete boundary.

A simplified policy could look like this:

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_projects ON projects
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

Set the tenant value transaction-locally from trusted server context. Test connection pooling carefully so context cannot leak between requests. Use a production application role that does not own the protected tables and cannot bypass RLS.

RLS is a backstop, not a reason to stop writing clear scoped queries. Explicit application queries improve readability and reduce surprises in jobs, migrations, and administrative paths.


Choose Pool, Silo, or a Tiered Model Deliberately

Tenant isolation does not automatically mean one database per customer.

AWS’s SaaS architecture guidance distinguishes logical isolation in shared resources from siloed infrastructure and emphasizes that authentication alone is not isolation. It also notes that regulated domains may impose stricter boundaries. Its isolation principles support three practical SaaS models.

Pooled

Tenants share application and database infrastructure. Rows, keys, object paths, cache entries, and metrics are partitioned by tenant context.

This is usually the most economical MVP model. It is operationally simple only when tenant scoping is designed into every repository, API, job, and storage key from the start.

Siloed

Each tenant receives dedicated databases, accounts, projects, or full stacks.

Silos can satisfy specific compliance, residency, performance, or contractual requirements. They also multiply migrations, monitoring, incident response, cost allocation, and configuration drift. “Separate database” does not remove the need for correct routing; sending a request to the wrong tenant’s database is still an isolation failure.

Tiered

Most tenants use pooled resources while selected enterprise tenants receive dedicated components.

This can turn isolation into a product capability, but only if the application uses stable tenant-aware interfaces. Business logic should not need to know whether the tenant maps to a shared database or a dedicated one.

For most early SaaS products, I would start pooled with strong logical isolation, document the reasons, and define the conditions that would trigger a silo. That follows the same reasoning behind boring SaaS architecture: choose the simplest model that meets the real requirement, while preserving a clean path to change.


The Failure Paths Teams Commonly Miss

The main API is rarely the only route to customer data.

Background jobs lose context

A request queues generate-report with only a report ID. The worker loads that record using a privileged database role. The original API had a tenant check; the job does not.

Include tenantId, the initiating principal, and a correlation ID in the job envelope. On execution, load the resource through a tenant-scoped repository and record the decision.

Object storage is treated as public plumbing

Database rows are scoped correctly, but file keys use /uploads/{fileId} and download URLs are signed without checking ownership.

Namespace object keys by tenant, authorize before issuing a signed URL, keep URLs short-lived, and test that a user cannot exchange one object identifier for another.

Caches omit the tenant key

project:${projectId} may collide when IDs are only unique inside a tenant or when a lookup bug supplies the wrong project. Use a key such as tenant:${tenantId}:project:${projectId} and apply the same rule to request deduplication and tags used for invalidation.

Support access bypasses normal policy

An internal dashboard often begins with a broad database query because only trusted staff can use it. That creates an unreviewed global-access path.

Require explicit tenant selection, short-lived elevation, a reason, and an audit record. Keep break-glass access narrow and visible.

AI and analytics create new readers

Retrieval pipelines, embeddings, evaluation datasets, and product analytics can copy tenant data into systems with different access models. Store tenant metadata with every derived artifact and apply the boundary during ingestion, retrieval, deletion, and export.

If you are adding autonomous workflows, extend the same isolation tests into the evaluation plan described in the production AI agent checklist. An agent’s tool permission does not prove that every record returned by the tool belongs to the active tenant.


A Tenant-Isolation Test Matrix

Happy-path tests prove that customers can use the product. Isolation tests prove they cannot use someone else’s product space.

Create tenant A and tenant B with predictable fixtures, then test each protected resource and action.

| Request | Expected result | |---|---| | Tenant A member reads tenant A resource | Allow | | Tenant A member reads tenant B resource by ID | Deny or not found | | Tenant A admin mutates tenant B resource | Deny | | Valid user sends a forged tenant header | Deny | | User switches to an organization without membership | Deny | | Worker runs with a missing tenant context | Fail closed | | Cache is warmed by tenant A, then queried by tenant B | No cross-tenant result | | Support user requests elevation without a reason | Deny | | Tenant is disabled during a long-running job | Re-check and stop safely |

Run this matrix at the API layer and against the real data-access configuration. Include GraphQL resolvers, server actions, route handlers, webhook consumers, exports, and bulk operations—not just REST endpoints.

Also test negative database behavior. Confirm the production role cannot read all rows when tenant context is absent, cannot bypass RLS, and cannot reuse context left on a pooled connection.

This belongs in a broader production-readiness review, because isolation affects architecture, operations, support, and product promises at the same time.


A Practical Two-Week Action Plan

You do not need to rebuild the product before improving the boundary.

Days 1–2: Map the tenant-owned surface

  • List tenant-owned tables, object stores, cache namespaces, queues, search indexes, analytics stores, and vector databases.
  • Mark every API, job, webhook, export, support tool, and migration that reads them.
  • Identify privileged credentials and table owners that bypass normal controls.

Days 3–4: Standardize trusted context

  • Define one RequestContext type.
  • Resolve tenant membership on the server.
  • Reject missing or ambiguous context.
  • Add tenant, principal, and request identifiers to structured logs without logging sensitive payloads.

Days 5–7: Make unsafe access inconvenient

  • Require tenant context in repository functions.
  • Scope reads and mutations in the query.
  • Namespace files and cache keys by tenant.
  • Add a shared policy-enforcement wrapper to protected entry points.

Days 8–10: Add data-layer guardrails

  • Evaluate RLS or the equivalent for the highest-risk tables.
  • Separate ownership and runtime database roles.
  • Test transaction-local context with the production pooler.
  • Document the paths that legitimately require cross-tenant access.

Days 11–12: Build the negative matrix

  • Create two-tenant fixtures.
  • Attempt reads, writes, deletes, exports, and file access across the boundary.
  • Exercise workers, webhooks, caches, and administrative tools.
  • Make the tests mandatory in CI.

Days 13–14: Write the decision record

  • State whether the model is pooled, siloed, or tiered.
  • Document trusted sources of tenant context.
  • Record where policy is decided and enforced.
  • Define enterprise triggers such as contractual isolation, residency, or dedicated performance.
  • Assign an owner and a review date.

The deliverable is not a diagram that says “multi-tenant.” It is an invariant your team can explain, test, and operate.


The Founder Decision

Do not wait for an enterprise questionnaire to discover how your product separates customers.

A small SaaS team does not need the most elaborate authorization platform. It needs one trusted tenant context, consistent enforcement at protected entry points, scoped access at the data layer, negative tests, and an explicit deployment model.

Build those foundations while the system is still understandable. They reduce security risk now and make future choices—custom roles, dedicated tenants, regional deployments, support tooling, and AI workflows—far less painful.

Review the boundary whenever a new data path is added, not only when the authentication system changes.

If the current boundary depends on developers remembering where tenant_id = ... in every new code path, a focused production-readiness upgrade should treat tenant isolation as a system property, not another item on a launch checklist.

Working on a SaaS that's starting to feel fragile?

Talk to an engineer about the parts that break first — without rewriting what already works. We'll recommend focused support or a compact team based on your scope.

Talk to an Engineer