← All case files

Case 001 · Multi-tenant B2B SaaS platform · Access control

Any customer could set any user’s password.

Account pages found users by ID alone, across every customer company.

A dark monitor showing a form: “Set a new password”, “For: another company’s admin”, a masked password and, in red, “Not your company”.
Before
Any logged-in customer could take over any account.
After
Recommended: server checks the tenant; two-tenant test in CI.

The case in half a minute.

A silent film with captions. The screens in it are illustrative.Illustrative

The bug

A multi-tenant B2B SaaS platform holds invoices and customer records for many companies, and each company should see only its own. The account pages carried the customer’s own tenant ID in the address, which looks like a boundary. The server never checked it. It found the target user from the user ID alone, and user IDs were small numbers in sequence.

How it shows up

Every customer saw their own users and records, so nothing looked wrong. Change the user ID by one, though, and the page showed a user from another company, with a password-set form addressed to their email. The permissions editor had the same flaw and listed that user’s email and access rights. Together, the two pages let any logged-in customer list every user on the platform and choose whose password to set, including the admins of other companies.

app.example.com/tenants/company-a/users/1043/account

403 · Not allowed

  • This user is not in your company.

Refused by the server

app.example.com/tenants/company-a/users/1043/account

Set a new password

  • For: admin@company-b.example
  • Company: Company B

Shown to a company A login

Drag to compare before and afterIllustrative

How we found it

During a security assessment we logged in as one customer and changed the user ID on each page that takes one. We proved the issue with a single record from another company, read-only, and stopped there. We submitted no form and changed no password.

The record pages were correctly scoped to each company, and the report says so; we report what works too.

The fix

The remediation we recommended: on the server, check that the target user belongs to the caller’s company and that the caller may manage them. Require the current password or a fresh sign-in to change a password. Replace sequential IDs with ones nobody can guess.

The test that catches it

Log in as company A and request every account-management page with a user ID from company B. Assert a 403 or 404, and assert that B’s email is not in the response. It runs in CI on every route that takes an ID.

two-tenant.spec.tsIllustrative
import { test, expect } from "@playwright/test";
import { accountRoutes, seed, signInAs } from "./fixtures";

// Every account page that takes a user ID, asked for another company's user.
for (const route of accountRoutes) {
  test(`company A can't open a company B user: ${route}`, async ({ page }) => {
    await signInAs(page, seed.companyA.admin);
    const userB = seed.companyB.admin;
    const response = await page.goto(route.replace(":userId", userB.id));

    expect([403, 404]).toContain(response?.status());
    await expect(page.getByText(userB.email)).toHaveCount(0);
  });
}

What it puts at risk

Every account on the platform, and through those accounts, every company’s invoices and customer records. Under GDPR, a takeover like this is a personal data breach for each company affected.

Tell us what you’re shipping.

An app, a rewrite, an AI agent, an automation, or a release that needs testing. A free 30-minute call, then a price in writing. If you don’t need us yet, we’ll say so.

Book a free call