Permission-aware global search design without data leaks
Learn how to design permission-aware global search with fast indexing and strict per-record access checks, so users get quick results without leaks.

Why global search can leak data
Global search usually means one search box that scans the whole app: customers, tickets, invoices, documents, users, and whatever else people work with. It often powers autocomplete and a quick results page so users can jump straight to a record.
The leak happens when search returns something the user isn't allowed to know exists. Even if they can't open the record, a single line like a title, a person's name, a tag, or a highlighted snippet can reveal sensitive information.
Search feels "read-only," so teams underestimate it. But it can disclose data through result titles and previews, autocomplete suggestions, result totals, facets like "Customers (5)," and even timing differences (fast for some terms, slower for others).
This usually shows up later, not on day one. Early on, teams ship search when there's only one role, or when everyone can see everything in a test database. As the product grows you add roles (support vs sales, managers vs agents) and features like shared inboxes, private notes, restricted customers, and "only my accounts." If search still relies on the old assumptions, it starts returning cross-team or cross-customer hints.
A common failure mode is indexing "everything" for speed, then trying to filter results in the app after the search query. That's too late. The search engine already decided what matches, and it may expose restricted records through suggestions, counts, or partial fields.
Imagine a support agent who should only see tickets for their assigned customers. They type "Acme" and autocomplete shows "Acme - Legal escalation" or "Acme breach notification." Even if clicking fails with "access denied," the title alone is a data leak.
The goal of permission-aware global search is simple to say and hard to implement: return fast, relevant results while enforcing the same access rules you apply when opening each record. Every query must behave as if the user can only see their slice of data, and the UI must avoid leaking extra clues (like counts) outside that slice.
What you're indexing and what you must protect
Global search feels simple because users type words and expect answers. Under the hood, you're creating a new surface area for data exposure. Before you pick an index or a database feature, get clear on two things: what objects you're searching (entities), and which parts of those objects are sensitive.
An entity is any record someone might want to find quickly. In most business apps, that includes customers, support tickets, invoices, orders, and files (or file metadata). It can also include people records (users, agents), internal notes, and system objects like integrations or API keys. If it has a name, an ID, or a status someone might type, it tends to end up in global search.
Per-record rules vs per-table rules
Per-table rules are blunt: either you can access the whole table, or you can't. Example: only Finance can open the Invoices page. This is easy to reason about, but it breaks down when different people should see different rows in the same table.
Per-record rules decide visibility row by row. Example: a support agent can see tickets assigned to their team, while a manager can see all tickets in their region. Another common rule is tenant ownership: in a multi-tenant app, a user can only see records where customer_id = their_customer_id.
These per-record rules are where search usually leaks. If your index returns a hit before you check row access, you've already revealed that something exists.
What "allowed to see" means in practice
"Allowed" is rarely a single yes/no switch. It usually combines ownership (created by me, assigned to me), membership (my team, my department, my role), scope (my region, business unit, project), record state (published, non-archived), and special cases (VIP customers, legal holds, restricted tags).
Write these rules down in plain language first. Later, you'll turn that into a data model plus server-side checks.
Decide what's safe to show in a result preview
Search results often include a preview snippet, and snippets can leak sensitive data even if the user can't open the record.
A safe default is to show only minimal, non-sensitive fields until access is confirmed: a display name or title (sometimes masked), a short identifier (like an order number), a high-level status (Open, Paid, Shipped), a date (created or updated), and a generic entity label (Ticket, Invoice).
Concrete example: if someone searches "Acme merger" and a restricted ticket exists, returning "Ticket: Acme merger draft - Legal" is already a leak. A safer result is "Ticket: Restricted" with no snippet, or no result at all, depending on your policy.
Getting these definitions right upfront makes later decisions simpler: what you index, how you filter, and what you're willing to reveal.
The basic requirements for safe, fast search
People use global search when they're in a hurry. If it takes longer than a second, they stop trusting it and go back to manual filtering. But speed is only half the job. A fast search that leaks even one record title, customer name, or ticket subject is worse than no search at all.
The core rule is non-negotiable: enforce permissions at query time, not just in the UI. Hiding a row after you fetched it is already too late, because the system has already touched data it shouldn't have returned.
The same applies to everything around search, not only the final results list. Suggestions, top hits, counts, and even "no results" behavior can leak information. Autocomplete that shows "Acme Renewal Contract" to someone who can't open it is a leak. A facet that says "12 matching invoices" is a leak if the user is only allowed to see 3. Even timing can leak if restricted matches make the query slower.
A safe global search needs four things:
- Correctness: every returned item is allowed for this user, for this tenant, right now.
- Speed: results, suggestions, and counts stay consistently fast, even at large scale.
- Consistency: when access changes (role update, ticket reassigned), search behavior changes quickly and predictably.
- Auditability: you can explain why an item was returned, and you can log search activity for investigations.
A useful mindset shift: treat search as another data API, not a UI feature. That means the same access rules you apply to list pages must also apply to index building, query execution, and every related endpoint (autocomplete, recent searches, popular queries).
Three common design patterns (and when to use them)
A search box is easy to build. A permission-aware global search is harder because the index wants to return results instantly, while your app must never reveal records the user can't access, even indirectly.
Below are three patterns teams use most often. The right choice depends on how complex your access rules are and how much risk you can tolerate.
Approach A: index only "safe" fields, then fetch after a permission check. You store a minimal document in the search index, like an ID plus a non-sensitive label that's safe to show to anyone who can reach the search UI. When a user clicks a result, your app loads the full record from the primary database and applies the real permission rules there.
This reduces leak risk, but it can make search feel thin because users get little context in results. It also needs careful UI wording so a "safe" label doesn't accidentally expose secrets.
Approach B: store permission attributes in the index and filter there. You include fields like tenant_id, team_id, owner_id, role flags, or project_id in each indexed document. Every query adds filters that match the current user's scope.
This gives fast, rich results and good autocomplete, but it only works when access rules can be expressed as filters. If permissions depend on complex logic (for example, "assigned OR on-call this week OR part of an incident"), it becomes hard to keep correct.
Approach C: hybrid. Coarse filter in the index, final check in the database. You filter in the index using stable, broad attributes (tenant, workspace, customer), then re-check permissions on the small set of candidate IDs in the primary database before returning anything.
This is often the safest path for real apps: the index stays fast, and the database remains the source of truth.
Choosing a pattern
Pick A when you want the simplest setup and can live with minimal snippets. Pick B when you have clear, mostly static scopes (multi-tenant, team-based access) and you need very fast autocomplete. Pick C when you have many roles, exceptions, or record-specific rules that change often. For high-risk data (HR, finance, medical), prefer C because "almost correct" isn't acceptable.
Step by step: design an index that respects access rules
Start by writing your access rules like you'd explain them to a new teammate. Avoid "admin can see everything" unless it's truly true. Spell out the reasons instead: "Support agents can see tickets from their tenant. Team leads can also see tickets from their org unit. Only the ticket owner and assigned agent can see private notes." If you can't say why someone can see a record, you'll struggle to encode it safely.
Next, choose a stable identifier and define a minimal search document. The index shouldn't be a full copy of your database row. Keep only what you need to find and display in the results list, such as title, status, and maybe a short, non-sensitive snippet. Put sensitive fields behind a second fetch that also checks permissions.
Then decide which permission signals you can filter on quickly. These are attributes that gate access and can be stored on every indexed document, like tenant_id, org_unit_id, and a small number of scope flags. The goal is that every query can apply filters before returning results, including autocomplete.
A practical workflow looks like this:
- Define the visibility rules for each entity (tickets, customers, invoices) in plain language.
- Create a search document schema with record_id plus only safe, searchable fields.
- Add filterable permission fields (tenant_id, org_unit_id, visibility_level) to every document.
- Handle exceptions with explicit grants: store an allowlist (user IDs) or group IDs for shared items.
Shared items and exceptions are where designs break. If a ticket can be shared across teams, don't "just add a boolean." Use explicit grants that can be checked by filters. If the allowlist is large, prefer group-based grants rather than individual users.
Keeping the index in sync without surprises
A secure search experience depends on one boring thing done well: the index must reflect reality. If a record is created, changed, deleted, or its permissions change, search results must follow quickly and predictably.
Keep up with create, update, delete
Treat indexing as part of your data lifecycle. A useful mental model is: every time the source of truth changes, you emit an event and the indexer reacts.
Common approaches include database triggers, application events, or a job queue. What matters most is that events aren't lost. If your app can save the record but fail to index it, you'll get confusing behavior like "I know it exists but search can't find it."
Permission changes are index changes
Many leaks happen when the content updates correctly, but access metadata doesn't. Permission changes come from role updates, team moves, ownership transfers, customer reassignment, or a ticket being merged into another case.
Make permission changes first-class events. If your permission-aware search relies on tenant or team filters, ensure indexed documents include the fields needed to enforce that (tenant_id, team_id, owner_id, allowed_role_ids). When those fields change, reindex.
The tricky part is blast radius. A role change might affect thousands of records. Plan a bulk reindex path that has progress, retries, and a way to pause.
Plan for eventual consistency
Even with good events, there will be a window where search lags behind. Decide what users should see in the first few seconds after a change.
Two rules help:
- Be consistent about delays. If indexing usually finishes within 2-5 seconds, set that expectation when it matters.
- Prefer missing over leaking. It's safer if a newly granted record appears slightly late than if a newly revoked record keeps showing up.
Add a safe fallback when the index is stale
Search is for discovery, but viewing details is where leaks hurt. Do a second permission check at read time before showing any sensitive fields. If a result slips through because the index is stale, the details page should still block access.
A good pattern is: show minimal snippets in search, then re-check permissions when the user opens the record (or expands a preview). If the check fails, show a clear message and remove the item from the visible result set on the next refresh.
Common mistakes that cause data leaks
Search can leak data even when your "open record" page is locked down. A user may never click a result and still learn names, customer IDs, or the size of a hidden project. Permission-aware global search has to protect not only documents, but also hints about documents.
Autocomplete is a frequent source of leaks. Suggestions are often powered by a fast prefix lookup that skips full permission checks. The UI looks harmless, but a single typed letter can reveal a customer name or an employee's email. Autocomplete must run the same access filter as full search, or be built from a pre-filtered suggestion set (for example, per-tenant and per-role).
Facet counts and "About 1,243 results" banners are another quiet leak. Counts can confirm something exists even if you hide the records. If you can't safely compute counts under the same access rules, show fewer details or omit counts.
Caching is another common culprit. Shared caches across users, roles, or tenants can create "result ghosts," where one user sees results generated for someone else. This can happen with edge caches, application-level caches, and in-memory caches inside a search service.
Leak traps worth checking early:
- Autocomplete and recent searches are filtered by the same rules as full search.
- Facet counts and totals are computed after permissions.
- Cache keys include tenant ID and a permission signature (role, team, user ID).
- Logs and analytics don't store raw queries or result snippets for restricted data.
Finally, watch out for over-broad filters. "Filter by tenant only" is the classic multi-tenant mistake, but it also happens inside one tenant: filtering by "department" when access is record-by-record. Example: a support agent searches "refund" and gets results across all customers in the tenant, including VIP accounts meant to be visible only to a smaller team. The fix is simple in principle: enforce row-level rules in every query path (search, autocomplete, facets, exports), not just in the record view.
Privacy and security details people forget
Many designs focus on "who can see what," but leaks also happen through the edges: empty states, timing, and tiny hints in the UI. Permission-aware search has to be safe even when it returns nothing.
One easy leak is confirmation by absence. If an unauthorized user searches a specific customer name, ticket ID, or email and gets a special message like "No access" or "You don't have permission," you've confirmed the record exists. Treat "no results" as the default outcome for both "does not exist" and "exists but not allowed." Keep response time and wording consistent so people can't guess based on speed.
Sensitive partial matches
Autocomplete and search-as-you-type are where privacy slips. Partial matches on emails, phone numbers, and government or customer IDs can expose more than you intend. Decide upfront how these fields behave.
A practical set of rules:
- Require exact match for high-risk fields (email, phone, IDs).
- Avoid showing highlighted snippets that reveal hidden text.
- Consider disabling autocomplete for sensitive fields entirely.
If showing even one character helps someone guess data, treat it as sensitive.
Abuse controls that don't create new risks
Search endpoints are ideal for enumeration attacks: trying many queries to map out what exists. Add rate limits and anomaly detection, but be careful with what you store. Logs that include raw queries can become a second data leak.
Keep it simple: rate limit per user, per IP, and per tenant; log counts, timing, and coarse patterns (not full query text); alert on repeated "near-miss" queries (like sequential IDs); and block or step up verification after repeated failures.
Make your errors boring. Use the same message and empty state for "no results," "not allowed," and "invalid filters." The less your search UI says, the less it can accidentally reveal.
Example: support team searching tickets across customers
A support agent, Maya, works on a team that handles three customer accounts. She has one search box in the app header. The product has a global index over tickets, contacts, and companies, but every result must obey access rules.
Maya types "Alic" because a caller said their name is Alice. Autocomplete shows a handful of suggestions. She clicks "Alice Nguyen - Ticket: Password reset." Before opening anything, the app re-checks access for that record. If the ticket is still assigned to her team and her role allows it, she lands on the ticket.
What Maya sees at each step:
- Search box: suggestions appear quickly, but only for records she can access right now.
- Results list: ticket subject, customer name, last updated time. No "you don't have access" placeholders.
- Ticket details: full view loads only after a second server-side permission check. If access changed, the app shows "Ticket not found" (not "forbidden").
Now compare that to Leo, a new agent in training. His role can only view tickets marked "Public to Support" and only for one customer. Leo types the same query, "Alic." He sees fewer suggestions, and none of the missing ones are hinted at. There's no "5 results" count that would reveal other matches exist. The UI simply shows what he can open.
Later, a manager reassigns "Alice Nguyen - Password reset" from Maya's team to a specialized escalation team. Within a short window (often seconds to a couple of minutes, depending on your sync approach), Maya's search stops returning that ticket. If she has the details page open and refreshes, the app re-checks permissions and the ticket disappears.
That's the behavior you want: fast typing and fast results, with no data scent leaking through counts, snippets, or stale index entries.
Checklist and next steps to implement safely
Permission-aware global search is only "done" when the boring edges are safe. Many leaks happen in places that feel harmless: autocomplete, result counts, and exports.
Quick safety checks
Before you ship, walk through these checks with real data, not samples:
- Autocomplete: never suggest a title, name, or ID the user can't open.
- Counts and facets: if you show totals or grouped counts, compute them after permissions (or omit counts).
- Exports and bulk actions: exporting "current search" must re-check access per row at export time.
- Sorting and highlighting: don't sort or highlight using fields the user isn't allowed to see.
- "Not found" vs "forbidden": for sensitive entities, consider the same response shape so users can't confirm existence.
A test plan you can run
Create a small role matrix (roles x entities) and a dataset with intentionally tricky cases: shared records, recently revoked access, and cross-tenant lookalikes.
Test it in three passes: (1) role matrix tests where you verify denied records never appear in results, suggestions, counts, or exports; (2) "try to break it" tests where you paste IDs, search by email or phone, and try partial matches that should return nothing; (3) timing and cache tests where you change permissions and confirm results update quickly with no stale suggestions.
Operationally, plan for the day search results "look wrong." Log the query context (user, role, tenant) and the permission filters applied, but avoid storing raw sensitive query strings or snippets. For safe debugging, build an admin-only tool that can explain why a record matched and why it was allowed.
If you're building on AppMaster (appmaster.io), a practical approach is to keep search as a server-side flow: model entities and relations in the Data Designer, enforce access rules in Business Processes, and reuse that same permission check for autocomplete, the results list, and exports so there's only one place to get it right.


