July 7, 2026
Stop Passing companyId Everywhere
How we handled multi-company context in a multi-tenant SaaS — without giving up security.

By Sule Akturk
6 min read
The Setup
If you've built a multi-tenant SaaS, you already know the first isolation boundary: tenant. One customer's data must never leak into another's. That part is usually solved early, and most teams get comfortable with it.
But there's a second, quieter problem that shows up once your product grows: a single tenant can own multiple companies, and a user needs to act on behalf of one of them — often switching between them in the same session, from a dropdown at the top of the screen.
The naive fix is to pass a companyId everywhere: as a query parameter, a body field, a prop drilled through every request. It works, technically. It also:
-
Pollutes every endpoint and every DTO with a field that has nothing to do with the actual business logic
-
Turns isolation into something every single service has to remember to filter by — and someone, eventually, forgets
-
Makes "switching company" feel like a heavyweight operation instead of a lightweight context change
There's also a sharper, more concrete failure mode hiding behind "someone forgets to filter." If companyId is something the client sends — a query param, a body field — then the only thing standing between a user and another company's data is every single endpoint remembering to double-check that the id in the request actually belongs to them. Miss that check once, on one endpoint, and a user can view or modify another company's invoices, vehicles, or records just by changing a number in the request. This is a textbook IDOR (Insecure Direct Object Reference) — and it doesn't take a malicious actor to trigger it either; it's just as often found by an automated scanner, or reported by a confused customer who noticed data that clearly isn't theirs. The more endpoints you have, the less realistic it is to assume everyone remembers, every time.
We wanted something different: a single cross-cutting channel for "which company am I acting as right now," decoupled from the request payload entirely.
The Core Idea: A Header, With a Fallback
The design settles on one rule, applied consistently everywhere:
If a
X-Company-Idheader is present, treat it as the active company. If it's absent, fall back to the user's home company from their JWT.
This single rule has a nice property: a user who only ever belongs to one company never has to think about this at all. No header, no extra state, nothing changes for them. The complexity only appears — and only needs to be handled — for users who actually have more than one company.
The home company (baked into the JWT at login) and the active company (resolved at runtime from the header) are deliberately different concepts. The token is not re-issued every time someone switches companies — that would mean a new login flow for what should feel like flipping a dropdown. The active company lives outside the token entirely, resolved per-request.
Why Trusting the Header Would Be a Mistake
Here's the catch: a header is just something the client sends. Nothing stops a malicious or buggy client from sending a company ID that isn't theirs — including a company that belongs to a different tenant altogether. If the backend blindly trusted the header, this becomes a textbook horizontal privilege escalation vector (essentially an IDOR pattern, just expressed through a header instead of a URL parameter).
The fix is a dedicated authorization step that runs right after authentication: before any controller logic executes, verify that the company referenced in the header actually belongs to the authenticated user's tenant. If it doesn't, the request is rejected outright with a 403 — never silently falling back, never partially processing.
This separates two concerns that are easy to conflate: authentication proves who you are; this extra check proves what you're allowed to act as right now. Skipping it means your isolation boundary technically exists in the data model but has a hole in the layer that's supposed to enforce it.
The Part That Actually Removes the Risk of Human Error
Validating the header solves the security problem. It doesn't solve the "someone forgot to filter by company in this one service" problem — that's a different failure mode, and it's arguably the more common one in practice, because it doesn't require malice, just an unlucky Tuesday.
The answer here is to stop relying on developers to remember at all. Instead of scattering WHERE company_id = … across every query, the isolation rule is expressed once, at the ORM level, as an automatic filter applied to every entity that belongs to a company. Every read query gets this constraint injected transparently; nobody writing a service method has to think about it.
This shifts company isolation from "a convention everyone has to follow" to "a property of the data access layer that's structurally difficult to bypass." The only way around it is an explicit, visible escape hatch — and that escape hatch should be rare and deliberate, reserved for the handful of cases where cross-company reads are genuinely legitimate (permission checks, the security validation step itself, background jobs).
Writes work the other way around: since there's nothing to "filter," new records simply get stamped with the resolved active company id at creation time, alongside the usual audit fields (who created it, when, soft-delete timestamps instead of hard deletes).
The Frontend Half of the Contract
None of this matters if the frontend doesn't hold up its end of the deal, and this is the part that's easy to underestimate — it's not just "add a header," it's a small state machine with several deliberate constraints:
Where the active company lives. Not in a token, not in memory alone — in a small piece of global state, persisted to local storage so it survives a refresh, and cleared automatically on logout so a new session never inherits a stale company.
Where the header actually gets attached. Rather than adding it to individual requests, it's injected in exactly one place — a shared request layer that every API call passes through — and, importantly, only on the subset of routes that actually expose a company switcher. Pages without a switcher always operate in the home-company context, regardless of what was selected elsewhere in the app. This scoping decision matters more than it looks: without it, a company switch made in one part of the product silently leaks into unrelated pages.
What happens on write, when nothing is selected. Reads can safely fall back to the home company when no company is actively selected. Writes are treated more cautiously — on pages where company context is ambiguous, a mutation without an explicit selection is blocked client-side with a clear message, rather than letting it quietly land against a default the user didn't consciously choose.
What happens when the backend says no. If the backend's validation step rejects a stale or invalid company id — which does happen, e.g. after switching accounts or when local storage holds an id that's no longer valid — the frontend needs a defined recovery path: clear the selection and redirect, rather than looping the user through repeated failed requests.
Why a full page reload, and not something more elegant. When the company switch happens, all already-cached data across the app is, by definition, scoped to the old company. And it's not just the cache: any request still in flight at the moment of the switch is carrying the old header, and its response would land on a screen that now belongs to the new context — a subtle way to end up with mixed data from two companies on one page. A full reload is a blunt instrument, but it solves both problems at once: every cached view is discarded, and every in-flight request dies with the old page. It's a trade of elegance for correctness, and for a context-switching action that isn't performance-critical, that's usually the right trade.
The Uncomfortable Truth About This Kind of Design
The rule sounds simple — "header, or fall back to home company" — but it ends up implemented in more than one place: at least twice on the backend (once for authorization enforcement, once for the actual data filter), and once more on the frontend. Three independent implementations of the same logic, that all need to agree, forever.
That's not a flaw unique to this approach — it's the honest cost of any cross-cutting concern that spans a network boundary. The mitigation isn't to eliminate the duplication (you can't, really) but to be deliberate about it: keep each implementation as thin and boring as possible, document the rule in one canonical place, and treat any change to it as touching three systems, not one.
Takeaways
If you're building something similar, the pattern that held up well is:
-
Carry cross-cutting context in a header, not the payload — it keeps business logic and access context cleanly separated.
-
Never trust a header for authorization — validate it against the authenticated identity before it's allowed to influence anything.
-
Enforce isolation structurally (a global filter at the data layer), not conventionally (a rule developers are expected to remember).
-
Scope the frontend's use of the header to exactly the pages that need it — don't let context leak app-wide by default.
-
Have an explicit recovery path for the moment validation fails, instead of assuming it never will.
None of these ideas are exotic on their own. What makes the system work is that they're applied consistently, on both sides of the network boundary, as one coherent rule rather than a pile of individual fixes.