Skip to main content

The browser is not a witness

· 5 min read
Rukia Labs Engineering
The team building the platform

We found the same hole twice, in two systems written a year and a half apart by people solving unrelated problems. Neither was a subtle cryptographic mistake. Both were the same sentence, written twice:

The browser told us which organisation this request is for, and we believed it.

What multi-tenancy has to guarantee

Every organisation on the platform has its own courses, its own people, its own grades and its own uploaded files. The guarantee is simple to state: a signed-in user of one organisation must not be able to read or write anything belonging to another.

It is simple to state and easy to get wrong, because almost every request has to carry an organisation id somewhere, and an id in a request looks the same whether the server verified it or merely received it.

The first one: a file-storage worker

Uploads used to go through a small worker that sat in front of object storage. The browser called it with the file, the target path, and the organisation id.

Two things were true of that worker:

  • It read orgId out of the request body.
  • It authenticated the caller with a shared secret that the browser had to hold in order to make the call at all.

So the credential proving "this request is allowed" was the same string for every user of every tenant, and it was shipped to every browser. And the field deciding which tenant's files to touch was supplied by the caller.

Anyone signed in to any organisation — or anyone who opened the network tab and read the secret — could list, read, write or delete in any other organisation's bucket. Not through a flaw in the logic. Through the logic working as written.

The second one: an AI service

Eighteen months later we added an assistant. The agent service behind it sits on a private network and identifies callers with an internal shared secret, which is correct for a service-to-service boundary.

Five call sites across three applications then fetched it straight from the browser, passing X-Org-Id and X-User-Id as headers.

The agent service verifies nothing. That is not a bug in it — it was built to be called by another service that had already done the verifying. Pointed at from a browser, its contract becomes "tell me who you are and I will believe you". Any signed-in user of any tenant could read another tenant's conversation threads and spend their rate limit, silently.

Same shape. Different decade of the codebase.

The rule we now hold

orgId is a claim the server verifies, never an instruction it follows.

Concretely, three things changed and all three matter:

The organisation moved into the path, and the path is checked. Asset calls go to /orgs/{orgId}/assets/* on our own API. The API resolves the caller from their access token, then requires a membership row joining that user to that organisation before it touches a bucket. The id in the URL is a claim being tested, not a parameter being honoured.

The browser stopped holding credentials. There is no shared secret in any bundle. The only thing the browser carries is the signed-in user's own token, which identifies exactly one person and grants exactly what that person has.

Every call to the private service goes through our API. The assistant's requests are proxied. The API resolves the caller, checks their membership, and forwards the organisation's verified internal id — a value the browser never sees and therefore cannot choose.

The old worker's path now answers 410 Gone rather than quietly disappearing, so anything still pointed at it fails loudly.

What we would tell someone auditing their own system

Ask where each id came from, not whether it is correct. "Is this org id valid?" is the wrong question — it was valid, it just belonged to someone else. The right question is "could the caller have chosen this value?"

A shared secret in a browser is not a secret. If the client must hold it to make the call, every client holds it. It authenticates the application, which is not a thing you needed to authenticate; it says nothing about the person.

A service that trusts its caller is fine until its caller is a browser. The agent service was not wrong. It was correct for the boundary it was designed for, and we moved it to a different boundary without moving its assumptions. That is the version of this mistake that is easiest to make and hardest to see in review, because the diff that introduces it is a URL change.

Rotate, do not just remove. The storage secret was shipped to browsers for as long as it existed. Deleting the variable does not un-ship it.

The check that would have caught both

Not a code review. Both of these passed code review — the code was clear, and each change was locally reasonable.

What catches it is a boundary question asked at design time: for each trust boundary in this request path, what is verified, by whom, and against what? Both holes are visible in a single sentence once somebody asks it. Neither is visible from inside the file where it lives.