Your database enforces tenancy in the repository layer, or with row-level security, or at least consistently in every query. Next to it sits a search index holding a denormalised copy of the same data, built by a pipeline, queried by a different code path, with the tenant filter added as a clause in the query.
A missing clause in a database query returns too much. A missing clause in a search query returns everything, across every customer, ranked by relevance.
This post is what to check about the index. For whoever added search after the product already existed, which is everyone.
The index is a second database
It is easy to think of as an accelerator. It is a data store, with its own copy of your data, its own update path, and usually far weaker controls than the primary.
Four properties make it worse than the original:
Filtering is by convention. Most search deployments have one credential with access to everything, and scoping is a clause the application adds. Nothing at the store refuses an unscoped query. Compare with row-level security, where forgetting the clause changes nothing.
It is denormalised on purpose. Fields are flattened together so they can be matched, which frequently means the index contains data the API never returns and the interface never shows.
It is eventually consistent by design. Deletes and permission changes arrive later, or not at all if the pipeline drops a message.
Relevance ranking is a disclosure channel of its own. Result counts, highlighted snippets and suggestions can reveal the existence and content of documents the user cannot open, even when the final fetch is authorised properly.
The failures worth testing
A missing or bypassable tenant clause. The direct case. Test by searching as a user of one tenant for a distinctive string that exists only in another.
Permission changes that never reach the index. Someone is removed from a project. The database reflects it immediately. The index still associates the document with them until a reindex that may be scheduled nightly, or manually, or never.
Deletes that leave the document. The record is gone from the database and remains searchable, which means your deletion request was not honoured and your search results contain records that no longer exist.
Fields in the index that should not be there. The pipeline indexes the whole row because that was simpler, including the columns the API deliberately omits. Then a snippet in a search result renders one.
Aggregations and counts. A faceted count that includes documents the user cannot read leaks their existence, and sometimes their distribution, which for small datasets is close to leaking the content.
The search backend reachable directly. In several deployments the search service listens on a port that the application reaches, and so does anything else in that network. It usually has no authentication at all.
What to do
Do not let the application construct the whole query. Wrap it in a layer that always applies the tenant and permission filter, and make the raw client unavailable to feature code. This is the same argument as a repository that refuses an unscoped query, and it holds for the same reason: relying on every caller to remember does not survive a growing team.
Separate by index where the boundary is strong. An index per tenant, or an alias per tenant, means a query cannot accidentally cross the boundary because the target does not contain the other data. This costs operationally, and shard count is a real constraint at scale, so it suits a small number of large tenants rather than a long tail.
Use document-level security where your engine offers it. The filter is enforced by the store based on the querying identity rather than added by the caller. This is the closest equivalent to row-level security.
Propagate deletes and permission changes as events, not on a schedule. If the index is updated by a nightly job, your worst case is a day of stale authorisation, and you should know that number and be able to defend it.
Index only what you need to match on. Fields that exist for retrieval rather than search belong in the primary store, fetched after the search returns identifiers. This also narrows what a snippet can reveal.
Put the index in your data inventory. It holds personal data, it is in scope for deletion requests, and it is a copy that is easy to forget when enumerating where data lives.
Check yours
# 1. As tenant A, search for a string that exists only in tenant B.
curl -s -H "Authorization: Bearer $TENANT_A_TOKEN" \
"https://api.example.com/search?q=tenant-b-unique-string" | jq '.total, .results[0]'
# want zero results
# 2. Delete a record, then search for it immediately and again after an hour.
# 3. Remove a user from a project, then search as them for its contents.
# 4. Is the search backend reachable without the application?
curl -s -o /dev/null -w "%{http_code}\n" -m 8 http://search-host:9200/_cat/indices
# anything other than a refusal is the finding
# 5. Read one indexed document in full and compare with what your API returns.
Test five surprises people most often. The index usually holds more than the interface does.
The concession
Per-tenant indices and event-driven reindexing add real operational weight, and for a product with thousands of small tenants the index-per-tenant pattern is the wrong shape entirely: too many shards, slow cluster state, and an operational burden out of proportion to the risk.
So the proportionate version for most products is a single index with a mandatory filter applied in a wrapper the feature code cannot bypass, plus event-driven deletes. That gets the two failures that actually cause incidents, cross-tenant results and undeleted records, without the operational cost of physical separation.
The implication
Search was added to make the product usable, by someone solving a relevance problem, and it became a second copy of your data with a different access control model that nobody wrote down.
Run the first test. Searching as one customer for another customer's distinctive string takes a minute and tells you which kind of search you have.