← Blog

A Chroma collection id was all it took to read another tenant’s data

Chroma’s authorization gate checks tenant, database and collection once, at the front. Downstream, the query and get paths resolved a collection by id alone and never asked whether it belonged to the caller’s tenant. Chroma has since shipped a fix, and we reproduced the gap live on both sides of the patch.

Executive summary

chroma-core/chromais an embedding store a lot of AI products build on. In June 2026 we threat-modeled its authorization model in Oplane and found that the query and get execution paths in the Rust core resolved a collection by id alone and never checked whether it belonged to the tenant asking for it. Anyone holding another tenant’s collection id could read or query that tenant’s data.

On 31 July 2026, Chroma’s own team merged PR #7249, "[BUG](frontend): enforce tenant scope on collection access", closing exactly that gap. The merge commit ends with two lines a security write-up does not usually get to quote: “Reported-by: Oplane security agent (oplane.io)” and “Co-authored-by: AI.”

This piece does two things. It walks through the original finding, the way Oplane surfaced it, and the code it pointed at. And it adds something the original disclosure did not have: we checked out the commit immediately before the fix, dropped in the fix’s own regression test with the fix itself removed, and watched it fail. Then we checked out the merge commit and watched the same test pass. Both runs are real, on real hardware, against real Rust code, not a description of what should happen.


Background: why we looked at Chroma

Chroma’s Rust core backs three things: the embedded Python and JS library (in-process, no network hop), a self-hosted server (started with chroma run), and Chroma Cloud, the hosted multi-tenant service with a SOC 2 Type II program. All three run through the identical frontend code, ServiceBasedFrontend (rust/frontend/src/impls/mod.rs:7 literally aliases Frontendto it). Chroma’s own “Local” and “Distributed” terms name something else entirely, the two Executor variants (rust/frontend/src/executor/mod.rs) that resolve a query against one process’s storage or route it across a cluster, orthogonal to whether the caller is the embedded library, a self-hosted server, or Cloud. What made us look closely was the hosted, multi-tenant case: a shared service holding other people’s vector data behind one authorization boundary is exactly the kind of surface where a single missed check has a large blast radius.

That authorization boundary only exists at the network edge. AuthenticateAndAuthorize is called once, in the HTTP server layer (rust/frontend/src/server.rs), before a request ever reaches ServiceBasedFrontend. The embedded library calls ServiceBasedFrontend’s methods directly, in the same process, with no server in front of them and no AuthenticateAndAuthorize call anywhere on that path. A tenant string passed to the embedded client is not a security boundary there, it never went through a gate, the same way no in-process call to a local database library is expected to enforce one on its own. What makes the missing check in this piece matter is the case where a real authentication boundary sits in front of ServiceBasedFrontend, a self-hosted single-node server, a distributed cluster, or Chroma Cloud, and the code behind that boundary does not hold up its end.

We reviewed chroma-core/chroma at commit 43171c54 (main, June 2026), the point where Oplane generated the threat model this piece is about.

How we found it: threat modeling with Oplane

We threat-modeled the tenant isolation boundary in Oplane, scoping it to the authorization gate, the sysdb catalog, the query and get execution paths, the worker and log layers, and the chroma-mcp agent integration. Oplane produced seven security requirements against that scope.

The grading was direct: five requirements Not Implemented, two Partially Implemented. The honest shape of the finding is that the authorization design is genuinely well-built (a granular AuthzAction enum, a collection-aware authorization hook, random UUIDv4 ids), but tenant isolation lives at a single frontend gate with nothing behind it. All grades are against the open-source commit. Chroma Cloud’s production authorization implementation is closed-source and outside the repo, so these are findings about the architecture of the trust model, not a claim that Cloud itself was exploited.

  1. Mandatory tenant and database authorization enforcement in the frontend gate

    Critical

  2. Explicit tenant validation on query and get execution paths

    Critical

  3. Defense-in-depth tenant and database scoping in data-layer collection resolution

    High

  4. Explicit tenant scoping and error handling in worker and log service layers

    High

  5. Scoped credential and collection allowlisting in the chroma-mcp agent integration

    Critical

This piece is about one of them, OPLANE_REQ-00087433, “Explicit Tenant Validation on Query and Get Execution Paths,” graded Critical. It is the one Chroma’s team fixed and publicly credited, which is what makes it possible to check our own work against theirs.


The mechanism

Chroma’s authorization design is a single-layer gate. Every request carries a tenant, a database and a collection, and every route handler in rust/frontend/src/server.rs calls AuthenticateAndAuthorize (the interface it implements is defined at rust/frontend-core/src/auth.rs:108-128) to check that triple once, at the front door, before the request is allowed through at all. That is a reasonable design as long as nothing downstream can be reached with a resolved object the gate never actually saw. Here it could be.

At the commit immediately before the fix, query() destructures its request with .., which silently drops the tenant_id field the request actually carried:

// rust/frontend/src/impls/service_based_frontend.rs:2788-2799
// (commit 1d9ddfad2, one commit before the fix)
pub async fn query(
    &mut self,
    QueryRequest {
        database_name,
        collection_id,
        ids,
        r#where,
        embeddings,
        n_results,
        include,
        ..                    // tenant_id lands here and is discarded
    }: QueryRequest,
) -> Result<QueryResponse, QueryError> {
    ...
    let collection_and_segments = self
        .collections_with_segments_provider
        .get_collection_with_segments(Some(database_name_typed.clone()), collection_id)
        .await
        .map_err(|err| Box::new(err) as Box<dyn ChromaError>)?;

get() does the identical thing (service_based_frontend.rs:2650-2668 at the same commit): destructure with .., resolve by (database_name, collection_id), never look at collection_and_segments.collection.tenant again. One level down, get_collection_with_segments in the sqlite catalog resolves by id with no tenant or database filter at all (rust/sysdb/src/sqlite.rs:634-640, calling get_collections_with_conn(conn, Some(collection_id), None, None, None, None, 0), tenant and database both None). Nothing between the gate and the database ever asked whose collection this was.

In other words, the id alone was the key. Whoever held a valid collection id could open the collection behind it, and nothing downstream ever checked that the hand holding the key was the hand it was cut for.

The gap is not a missing feature. The scaffolding for the correct check already existed: a collection-aware authorization hook that takes the fetched collection as an argument (auth.rs:116-122). Nothing downstream of the gate called it on the read path. That is a specific, traceable omission, not a vague design weakness.


Reproduction

We reproduced both sides of this live: not a description of what a test would show, but two real cargo test runs, against two real commits, in a local checkout of the actual repository.

Pre-fix: watching the gap fail

We checked out chroma-core/chroma at commit 1d9ddfad2, the parent of the merge commit, one commit before the fix landed. We took the regression test Chroma’s own PR later shipped, read_paths_reject_collection_from_other_tenant, and inserted it verbatim into that pre-fix checkout, with the fix itself absent. The test creates a collection under one tenant, seeds it with a record, then calls get, query and count against that same collection id while authenticated as a different tenant, and asserts every call returns NotFound.

$ cargo test -p chroma-frontend --lib \
    oplane_repro_read_paths_reject_collection_from_other_tenant -- --nocapture

running 1 test
thread '...' panicked at rust/frontend/src/impls/service_based_frontend.rs:3496:25:
PRE-FIX GAP CONFIRMED: get() as tenant 'other_tenant' returned Ok with 1 id(s)
(["id1"]) from a collection created by tenant 'default_tenant', instead of NotFound

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 97 filtered out

That is the vulnerability, executing. A request authenticated as one tenant asked for a record belonging to another tenant, by id, and got it back.

Post-fix: watching the gap close

We then checked out the merge commit itself, aed1ab8ac8, and ran Chroma’s own official test, read_paths_reject_collection_from_other_tenant, unmodified, as it ships in their tree today:

$ cargo test -p chroma-frontend --lib \
    read_paths_reject_collection_from_other_tenant -- --nocapture

running 1 test
test impls::service_based_frontend::tests::read_paths_reject_collection_from_other_tenant ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 102 filtered out

Same test, same shape of request, one commit apart. It fails before the patch and passes after it. That is close to a controlled experiment for a single-commit fix: the only material difference between the two runs is the code Chroma’s own PR changed.

A note on scope, for anyone checking our work the way we checked Chroma’s. Both runs are unit tests against ServiceBasedFrontenddirectly, over an in-memory sqlite backend, the same harness Chroma’s own test suite uses for this code. We did not stand up a full Chroma Cloud deployment or send requests over a network; the fix itself sits underneath the real HTTP handlers (server.rs wires .query() and .get() directly to the API routes behind the x-chroma-tokenauth extraction), but this reproduction exercises the frontend logic layer, not a live network request against a running server. We say that plainly because the point of this piece is to not ask anyone to trust a framing, ours or Chroma’s, without being told exactly what was run.


The fix

Chroma’s own team merged PR #7249 on 31 July 2026, authored by the most active committer on this part of the codebase. The fix adds one function, validate_collection_scope, that takes the collection just fetched and compares its tenant (and database, where known) against the tenant on the request. Match, and the call proceeds. Mismatch, and it logs a warning and returns NotFound, the same response a caller gets for a collection that does not exist, so a rejected cross-tenant request cannot even confirm that some other tenant’s collection exists.

In other words, the fix adds the one question that was never being asked: now that we have found the thing this id points to, does it actually belong to the tenant asking for it? Everything else about the request handling stays the same; only that one check was missing, and now it runs on every read.

// rust/frontend/src/impls/service_based_frontend.rs:397-421 (validate_collection_scope)
// called from get_collection_with_segments_for_tenant (:423-440)
// and get_cached_collection_for_tenant (:1199-1211), the resolution
// path for count (:2668), get (:2804), query (:2959) and roughly
// twenty other call sites in the same file.

The team threaded that check through every read path that used to skip it, roughly two dozen call sites across one file, not a single patched endpoint, and shipped the regression test we ran above as part of the same PR. Both are still present and passing at HEAD.

Worth saying plainly, this closes the specific Critical requirement,OPLANE_REQ-00087433, and it is a fix at the frontend, immediately after resolution, exactly where that requirement asked for a check. It is not, on its own, a second independent check at the catalog layer. rust/sysdb/src/sqlite.rs:634-640still resolves a collection by id alone, unchanged, verified against the current HEAD of the repository as of this piece. Chroma’s own PR description calls the change “defense-in-depth,” which is an honest description of what one well-placed gate buys, not a claim of a rebuilt data layer. This piece covers the one Critical that got fixed and publicly credited. It is not a claim that all seven original requirements are now closed.

The merge commit is publicly attributed:

Reported-by: Oplane security agent (oplane.io)
Co-authored-by: AI

Timeline

All dates as reported or as verified against the actual commits.

  1. Oplane threat-models the tenant isolation boundary in chroma-core/chroma at commit 43171c54.
  2. Chroma’s own team engages the finding directly, confirms it is worth fixing, and asks how to credit it.
  3. Chroma merges PR #7249, the fix, and credits Oplane by name in the commit message.
  4. We reproduce the gap live: fails against the pre-fix commit, passes against the merge commit.
  5. This piece publishes.

Closing thoughts

A single authorization gate at the front door is a clean design, and it is also the exact shape that hides this kind of gap. Every individual function downstream is doing precisely what it was asked to do: resolve an id, return the data. Nothing about any one function looks wrong in isolation. The gap only shows up when you trace what happens to an id after the gate lets it through, and ask whether anything downstream still knows, or cares, who it belonged to.

That question does not answer itself by reading a pull request description, including this one. It answers itself by checking out the commit before the fix, running the test the fix later shipped, and watching it fail for the reason you said it would. We did that twice this session, once each direction, and both runs matched what the code said they should do.

Anyone can publish a threat model and say a finding held up. What is harder to fake is a maintainer merging the fix into production code, months later, with no prompting, and a public commit history that lets a stranger reproduce both the break and the repair from the outside.


The PR is public: PR #7249. The original threat model is public too, at oplane.com/threat-model/chroma-core-chroma-mcp-single-layer-tenant-isolation-frontend. Read the requirement, the diff, and the test side by side and check our reproduction steps against your own checkout.

See what Oplane finds in your own repo

Oplane threat-models the whole system, not just the diff, tenant boundaries included. Free to test.

We value your privacy

We use cookies to make the site work better for you and to analyze traffic. You can accept all cookies, customize your settings, or reject non-essential cookies.