Safeguard
Application Security

Secure multi-tenant SaaS access control patterns

Broken Access Control has topped OWASP's Top 10 for two straight cycles, found in 100% of tested apps in 2025 — most of that risk starts with one missing tenant_id check.

Safeguard Research Team
Research
6 min read

Broken Access Control has been the #1 category in the OWASP Top 10 since 2021, when it jumped from fifth place after testers found some form of it in 94% of applications and logged over 318,000 occurrences across 34 mapped CWEs — more than any other category that year. The OWASP Top 10:2025, finalized in January 2026, kept it at #1 for a second consecutive cycle and reported it in 100% of tested applications, while expanding its CWE coverage from 34 to 40 by folding in server-side request forgery as a specific manifestation of improper access control. Separately, the OWASP API Security Top 10 lists Broken Object Level Authorization — an endpoint trusting a client-supplied ID without verifying the requester owns the underlying record — as API1, its single most common vulnerability class. In a multi-tenant SaaS backend, that exact failure mode becomes tenant data leakage: Tenant A's request returns Tenant B's records because the query never checked which tenant issued it. This post walks through the concrete patterns that prevent that, and the ones that only look like they do.

Why isn't checking tenant_id at the API gateway enough?

Checking tenant_id at the API gateway isn't enough because the gateway only verifies who is calling — it can't verify which specific record a downstream query will touch. A gateway or middleware layer typically authenticates the request and attaches a tenant_id claim from a JWT, then trusts every service behind it to honor that claim. The leak happens two or three layers down: a service method takes an object ID from the request body, loads it directly with SELECT * FROM invoices WHERE id = ?, and never adds AND tenant_id = ?. The gateway did its job — it correctly identified the caller — but the query itself is tenant-blind. This is structurally the same bug OWASP's API1 (BOLA) describes: authorization was checked at the door, not at the object. Every data-access path, including background jobs, admin tooling, cache layers, and search indexes, needs its own tenant scoping, because any one of them can become the leak if it re-derives its own query logic instead of inheriting a shared, tenant-aware data access layer.

What does defense-in-depth tenant isolation actually look like at the database layer?

Defense-in-depth at the database layer means enforcing tenant boundaries even if the application code forgets to, typically through row-level security (RLS). PostgreSQL has supported native RLS since version 9.5 (2016): a policy such as CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.tenant_id')::uuid) makes the database itself refuse to return rows outside the session's tenant context, regardless of what the application's WHERE clause says. This matters because application-layer scoping fails silently — a missing filter returns wrong data with no error — while RLS fails loudly, returning zero rows or throwing when the session variable isn't set. The tradeoff is operational complexity: every connection pool, ORM session, and background worker must set the tenant context before querying, and pooled connections that don't reset it between requests can leak context across tenants. Teams running schema-per-tenant or database-per-tenant models sidestep RLS entirely by making cross-tenant queries structurally impossible, at the cost of harder migrations and higher infrastructure overhead as tenant count grows into the thousands.

How does IDOR become a tenant-leakage bug, and how did Peloton's case show the pattern?

IDOR (Insecure Direct Object Reference) becomes a tenant-leakage bug the moment an object identifier is guessable or sequential and the endpoint doesn't verify ownership before returning data. Peloton's May 2021 incident is a clean illustration of the root pattern, even though it wasn't a strict multi-tenant SaaS case. Researcher Jan Masters of Pen Test Partners reported on January 20, 2021 that Peloton's API returned other users' private profile data — age, gender, city, weight, workout stats, and birthday details — to requests that supplied a user ID without proving the requester was authorized to see it. Peloton's first patch, applied around February 2, only required any authenticated account, not the correct one — the object-level check was still missing, just gated behind a login screen. The full fix landed roughly seven days after press involvement, around 90 days after the original report (TechCrunch, Pen Test Partners). The lesson for tenant isolation: authentication and object-level authorization are two separate checks, and fixing one without the other leaves the leak intact.

Why do automated scanners miss these bugs, and what testing actually catches them?

Automated scanners miss these bugs because BOLA and tenant-scoping failures are logic errors, not syntax patterns — there's no dangerous function call or malformed string for a SAST rule to flag. A scanner can verify that a query is parameterized against SQL injection, but it can't know that the query is missing a tenant_id predicate that should be there, because "should be there" depends on your schema and business rules, not the language grammar. This is why OWASP's own guidance for API1 recommends authorization tests that authenticate as Tenant A and then attempt to fetch, modify, or delete a resource ID that belongs to Tenant B — a test class often called "cross-tenant" or "horizontal" authorization testing, distinct from checking whether a low-privilege role can access an admin-only endpoint (vertical authorization). Effective coverage means writing this test once per resource type and running it in CI on every new endpoint, rather than treating it as a one-time pentest exercise, since a single new API route with a copy-pasted query is enough to reintroduce the exact same gap.

What does centralizing authorization logic buy you that per-endpoint checks don't?

Centralizing authorization logic buys consistency: a single policy engine or shared data-access layer enforces the tenant and ownership check the same way everywhere, so a developer adding endpoint #401 inherits the same guarantee as endpoint #1 instead of having to remember to re-implement it. Duplicated, per-endpoint authorization checks are exactly how these bugs slip in at scale — each handler reimplements "does this belong to the caller's tenant," and it only takes one handler skipping it under deadline pressure. Patterns that centralize this include repository-layer scoping (every data-access method requires and applies a tenant context, with no raw-query escape hatch), middleware-enforced policy objects, and, for internal tooling, hierarchical RBAC models where permissions are evaluated against an explicit tenant/organization/project tree rather than ad hoc flags scattered through the codebase — Safeguard's own Portal role system is one concrete example, evaluating a set of predefined business roles (spanning tenant-level, organization-level, and functional roles such as security, compliance, and engineering) against a tenant-and-organization hierarchy so a single permission matrix, not per-route logic, decides what a given role can reach. The goal either way is the same: make the correct check the path of least resistance, not something each engineer has to remember to add.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.