The Tenant Boundary That Only Existed in the Frontend
A client's multi-tenant SaaS had menus, routes, and role checks — all in the browser. The API behind it would happily serve any tenant's data to anyone with a valid session token. It took eight months for someone to notice.
The engagement started as a performance audit. A mid-size B2B SaaS company — about 300 customers, each with their own workspace — was seeing slow dashboard loads and wanted help optimizing their queries. Standard stuff. But while I was tracing request patterns through their API, I noticed something that made me stop scrolling through logs.
A GET request to /api/workspaces/47/reports/312 returned a full report payload. The authenticated user belonged to workspace 12.
I checked the API controller. No tenant filter.
How the illusion worked
The application was a React frontend backed by a Node.js API. The frontend did everything right, from a UI perspective. When a user logged in, the app fetched their workspace ID and scoped every navigation menu, every route, and every data fetch to that workspace. A user in workspace 12 would never see a link to workspace 47's reports. The sidebar only showed their projects. The URL bar always had their workspace ID.
The problem was that all of this lived in the browser. The API endpoints accepted a workspace ID as a path parameter and returned whatever was in the database for that ID. The authentication middleware verified the JWT was valid — it checked that you were a user. It never checked that you were a user of that workspace.
// What the middleware did
const verifyAuth = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
};
// What it should have also done
const verifyTenantAccess = async (req, res, next) => {
const workspaceId = req.params.workspaceId;
const membership = await db.workspaceMembership.findFirst({
where: { userId: req.user.id, workspaceId: parseInt(workspaceId) },
});
if (!membership) return res.status(403).json({ error: "Forbidden" });
next();
};The first middleware was on every route. The second one didn't exist.
The blast radius
I spent a day mapping out which endpoints were affected. Out of 68 API routes that took a workspace ID parameter, every single one was vulnerable. Reports, invoices, user lists, file attachments, billing history. If you had a valid JWT from any tenant, you could enumerate workspace IDs and pull data from all of them.
The data wasn't encrypted at rest per tenant. There was no row-level security in PostgreSQL. The only thing standing between tenant A and tenant B's data was the React router.
I wrote a quick script that iterated workspace IDs 1 through 400 against three sensitive endpoints. It completed in under two minutes and returned data for 287 active workspaces. Names, email addresses, uploaded documents, financial reports.
When I showed the results to the CTO, the room went quiet. Their largest customer was a healthcare company. They had compliance obligations. This wasn't a theoretical risk — it was an active exposure that had been live since launch.
How it happened
The answer, like most security gaps, was organizational. The original backend was built by a small team that also built the frontend. In their mental model, the frontend was the access control layer. Early API tests used the frontend as the client, so requests always had the "right" workspace ID. Nobody tested what happened when they didn't.
As the team grew, new developers inherited the pattern. They saw the existing controllers, used them as templates for new routes, and assumed authorization was handled somewhere upstream. The middleware chain checked auth. Wasn't that enough?
Code review never caught it because reviewers were looking at the code in front of them, not at the code that was missing. There's no lint rule for "you forgot to check tenant membership." The test suite was extensive — over 1,200 tests — but every test authenticated as a user and then accessed that user's own workspace. The happy path was thoroughly verified. The attack path was never exercised.
The fix
We took a three-phase approach. First, a tenant-scoping middleware that rejected any request where the authenticated user lacked a membership in the target workspace. That went out within 48 hours as a hot patch.
Second, we added row-level filtering at the database query layer. Every query that touched tenant data now included a WHERE workspace_id = $userWorkspaceId clause, pulling the workspace from the verified JWT claims rather than from the URL parameter. Belt and suspenders.
Third — and this took longer — we wrote integration tests that explicitly attempted cross-tenant access. For each endpoint, the test authenticated as a user in workspace A and tried to access a resource in workspace B. Those tests would catch any future regression, and any new endpoint that forgot to include tenant scoping.
Warning
The uncomfortable question
We checked access logs going back six months. There was no evidence of exploitation — no unusual cross-workspace access patterns, no scraping behavior. The exposure existed, but as far as we could tell, nobody had found it. Whether that was luck or obscurity, I couldn't say.
The audit ended up being more valuable than the performance work I was originally hired for. The query optimization saved them a few hundred milliseconds on dashboard loads. The authorization fix prevented what could have been a breach notification to 300 customers.
What stuck with me from this one is how invisible the problem was. The app looked correct. The tests passed. Users saw only their own data. Every signal said "this is fine." It took someone looking at raw HTTP logs from a completely different angle to notice that the boundaries everyone assumed were there simply weren't.
How many apps are running right now with the same assumption — that the frontend is the authority on who gets to see what?