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.
403 · Not allowed
- This user is not in your company.
Refused by the server
Set a new password
- For: admin@company-b.example
- Company: Company B
Shown to a company A login
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.
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.
