
Multi-Tenant Postgres Search: Across vs Within Tenants
An engineering pattern for the reads that must cross tenant boundaries — without weakening the isolation the rest of your app depends on.
The problem
The architecture is clean for the first six months. Every row carries a tenant_id. Every query runs as a Postgres role whose session has app.current_tenant set. Row-level security policies do the visibility work, and the only thing application code has to remember is to set the session variable on connection checkout. The audit story is short: tenants cannot see each other's rows because the database will not let them.
Marketplace features collide with tenant isolation
Then the marketplace feature lands. A buyer needs to search every vendor in the catalog. The leaderboard needs to rank participants across teams. The public profile page needs to render a creator's content for anyone with the URL. Suddenly the architecture that made tenant isolation simple is in the way of the feature spec. The first instinct is to add a flag to the query and call it done. The second instinct, after the first security review, is to add a SECURITY DEFINER function. The third instinct, after the first slow-query page at 3am, is to add Meilisearch.
Cross-tenant path leaks or stalls
The bug shape that follows is consistent. Either the cross-tenant path leaks — a developer copies the unsafe query for a new feature and the bypass spreads, or someone forgets to filter by is_public in the materialized view, or the external search index gets seeded from a source that does not respect the same visibility rules the live table does. Or the cross-tenant path is slow — the GIN index is sized for per-tenant reads, the materialized view refresh holds an exclusive lock for two minutes, the Meilisearch indexer falls behind by an hour and the catalog shows yesterday's prices.
Contract breach on one side, missing feature on the other
The business consequence is real on both sides. A leak is a contract breach with a customer who paid for tenant isolation, and a slow leaderboard is a feature that does not ship. The pattern below is what keeps the cross-tenant read path explicit, auditable, fast, and bounded — so the rest of the app keeps its strong default isolation, and only one well-marked path crosses the boundary.

What changes for your business
The architecture has four moving parts that have to agree: a default RLS posture that denies cross-tenant reads, an explicit cross-tenant read path that bypasses the default in a single audited place, an index strategy that matches the access pattern (per-tenant FTS vs cross-tenant FTS vs materialized view vs external search), and an indexer that keeps any external search backend tenant-aware at write time. Each piece does one job, and the seams between them are where the audit lives.
Forced RLS as the default posture
Start with the default. Tenant tables have RLS enabled, and the table owner is forced subject to RLS so even an owner-as-app connection cannot accidentally bypass. Postgres documents that table owners normally bypass row security and that you opt in to enforcement with ALTER TABLE ... FORCE ROW LEVEL SECURITY — for a multi-tenant app you want the forced posture, because the app role is often the owner of its own tables.
ALTER TABLE listings ENABLE ROW LEVEL SECURITY;
ALTER TABLE listings FORCE ROW LEVEL SECURITY;
-- PERMISSIVE by default: visible if you own the tenant.
CREATE POLICY listings_tenant_read
ON listings
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- RESTRICTIVE: hide if soft-deleted, regardless of any other policy.
CREATE POLICY listings_not_deleted
ON listings
AS RESTRICTIVE
FOR SELECT
USING (deleted_at IS NULL);
-- RESTRICTIVE: hide if the tenant is suspended.
CREATE POLICY listings_tenant_active
ON listings
AS RESTRICTIVE
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM tenants t
WHERE t.id = listings.tenant_id
AND t.status = 'active'
)
);
The two RESTRICTIVE policies are the load-bearing piece most teams miss. Permissive policies combine with OR, so adding another permissive policy can only grant more visibility. Restrictive policies combine with AND on top of the permissive set, so they can take visibility away in ways no future permissive policy can relax. Soft-delete and tenant-suspended are exactly the rules you want restrictive — once a row is deleted or a tenant is suspended, no clever "but the catalog needs to see it" policy should be able to undo that.
SECURITY DEFINER as the single cross-tenant entry
Now the cross-tenant read path. The pattern that scales is a SECURITY DEFINER function that the app calls when it explicitly needs to cross the tenant boundary. The function owns the visibility rules for the cross-tenant case — typically "the row is published AND not deleted AND the tenant is active AND not opted-out-of-catalog." The function has its search_path locked down because Postgres documents that a SECURITY DEFINER function runs with the owner's privileges and a writable schema in the path is a privilege-escalation hazard.
CREATE OR REPLACE FUNCTION public.catalog_search(q text, lim int DEFAULT 50)
RETURNS TABLE (
listing_id uuid,
tenant_id uuid,
title text,
rank real
)
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
SELECT
l.id,
l.tenant_id,
l.title,
ts_rank(l.search_vector, websearch_to_tsquery('english', q)) AS rank
FROM listings l
JOIN tenants t ON t.id = l.tenant_id
WHERE l.is_published = true
AND l.deleted_at IS NULL
AND t.status = 'active'
AND t.catalog_opt_out = false
AND l.search_vector @@ websearch_to_tsquery('english', q)
ORDER BY rank DESC
LIMIT lim;
$$;
REVOKE ALL ON FUNCTION public.catalog_search(text, int) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.catalog_search(text, int) TO app_user;
That function is the entire cross-tenant read surface. A security review can grep for SECURITY DEFINER and find every path that crosses the boundary, instead of trying to reason about which application code paths might have bypassed RLS with a service-role connection. The Postgres docs note this directly: policy expressions run with the caller's privileges, but security-definer functions can access data the caller cannot — which is exactly the property you want here, scoped to one named function instead of an entire service-role connection.
GIN index strategy matched to access pattern
The index strategy follows the access pattern. Within-tenant search uses a single GIN index on the tsvector column for the whole table, with the tenant filter enforced by RLS. Cross-tenant catalog search uses the same GIN index, called from the SECURITY DEFINER function, with a partial-index variant filtered on is_published = true AND deleted_at IS NULL if the catalog is materially smaller than the table. The tsvector itself lives in a generated column so Postgres maintains it automatically on write — Supabase's FTS guide recommends this pattern specifically because it removes the "did we forget to update the search vector" class of bug.
ALTER TABLE listings ADD COLUMN search_vector tsvector;
-- Maintain the tsvector via a trigger so writers do not have to remember.
-- (Supabase's FTS guide also documents a generated-column form; pick the
-- one that fits your migration tooling.)
CREATE OR REPLACE FUNCTION listings_search_vector_refresh()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.description, '')), 'B');
RETURN NEW;
END;
$$;
CREATE TRIGGER listings_search_vector_bi
BEFORE INSERT OR UPDATE OF title, description
ON listings
FOR EACH ROW EXECUTE FUNCTION listings_search_vector_refresh();
-- Within-tenant search index (used by the RLS-protected path).
CREATE INDEX listings_search_vector_gin
ON listings USING GIN (search_vector);
-- Cross-tenant catalog index — narrower, faster for the catalog query.
CREATE INDEX listings_catalog_search_gin
ON listings USING GIN (search_vector)
WHERE is_published = true AND deleted_at IS NULL;
Two index choices worth flagging. First, the setweight calls let ts_rank boost title matches over description matches without changing the query, which is the cheapest ranking improvement available in Postgres FTS. Second, the partial cross-tenant index pays off when the catalog is, say, 10% of total rows — at that ratio, the partial index is roughly 10x smaller and disk-cache-friendlier than the full index, and the planner picks it for the catalog path automatically.
Materialized views for snapshot reads
For features that need a pre-aggregated cross-tenant view — leaderboards, public profile counts, "top N" widgets — the materialized view pattern is the right tool. The view is a snapshot, refreshed on a cadence that matches your freshness budget, and refreshed CONCURRENTLY so reads stay available during the refresh.
CREATE MATERIALIZED VIEW public_creator_profile AS
SELECT
c.id AS creator_id,
c.tenant_id,
c.display_name,
count(p.id) AS published_post_count,
max(p.published_at) AS last_published_at,
to_tsvector('english',
coalesce(c.display_name, '') || ' ' || coalesce(c.bio, '')
) AS profile_search_vector
FROM creators c
LEFT JOIN posts p
ON p.creator_id = c.id
AND p.is_published = true
AND p.deleted_at IS NULL
WHERE c.is_public_profile = true
AND c.deleted_at IS NULL
GROUP BY c.id;
-- Required by REFRESH ... CONCURRENTLY.
CREATE UNIQUE INDEX public_creator_profile_pk
ON public_creator_profile (creator_id);
CREATE INDEX public_creator_profile_search
ON public_creator_profile USING GIN (profile_search_vector);
-- Refresh on a schedule that matches the freshness budget.
REFRESH MATERIALIZED VIEW CONCURRENTLY public_creator_profile;
The unique index is non-negotiable for CONCURRENTLY — Postgres documents that the option is only allowed when at least one UNIQUE index on the view uses only column names and covers every row. Skipping that index is the most common production failure mode for materialized views: the first refresh works (without CONCURRENTLY), the second one (added later for performance) errors out, and now you are shipping an emergency migration during a Friday afternoon refresh.
External search backends inherit visibility from a view
When you do need an external search backend — typo tolerance, deep faceting, ranking on user signals — Meilisearch and Typesense both work, and both have the same multi-tenant trap. The trap is treating the external index as the source of truth for visibility. It is not. The source of truth is Postgres, and the external index is a derived projection of Postgres that must respect the same visibility rules at write time.
// indexer.ts — runs in a background worker, with a connection that
// reads through the SECURITY DEFINER function (or the catalog view),
// not directly from the raw table with a service-role bypass.
async function reindexListing(listingId: string): Promise<void> {
const row = await db.query<CatalogRow>(
`SELECT listing_id, tenant_id, title, description
FROM catalog_view
WHERE listing_id = $1`,
[listingId],
);
if (row.rows.length === 0) {
// Row is no longer in the catalog (deleted, tenant suspended,
// unpublished). Remove it from the external index too.
await meili.index("listings").deleteDocument(listingId);
return;
}
await meili.index("listings").addDocuments([row.rows[0]]);
}
// search.ts — every query is filtered by the caller's allowed-tenant
// set, constructed server-side from the authenticated session.
async function searchListings(
session: AuthedSession,
query: string,
): Promise<SearchHit[]> {
const allowedTenants = await resolveAllowedTenantsForSearch(session);
// The filter is constructed from the session, not from the request body.
const filter = `tenant_id IN [${allowedTenants.map((t) => `"${t}"`).join(",")}]`;
const result = await meili.index("listings").search(query, {
filter,
limit: 50,
});
return result.hits;
}
resolveAllowedTenantsForSearch returns one of three things: the single tenant the user is logged into (within-tenant search), the global "catalog" pseudo-tenant tag for public marketplace search, or a union for users with multi-tenant access. The function lives next to the auth layer and is the single source of truth for "who can search what" — the search handler does not read tenant filters from the client. That is the rule that prevents the IDOR-shaped bug where a user changes a tenant ID in the request and gets back another tenant's data.

Common failure modes
The first sharp edge is the service-role bypass. Most managed Postgres setups ship a service-role connection that has BYPASSRLS or owns the tables. It is convenient for migrations and one-off jobs and an absolute trap for application reads. The app should not hold the service-role credential at runtime. The Postgres docs are explicit on what that credential does: superusers and BYPASSRLS roles bypass the row security system on every access, and table owners normally do too. Every cross-tenant read path that depends on the service-role credential is one config change away from leaking.
The second is the SECURITY DEFINER without a locked-down search_path. The Postgres docs walk through the attack: a function that owner-runs and does not pin its search_path can be hijacked by a temporary table the attacker creates with the same name as something the function references. The mitigation is one line at the bottom of the function definition — SET search_path = public, pg_temp with pg_temp last. Reviewers should look for it on every SECURITY DEFINER function the same way they look for parameterized queries.
The third is the cross-tenant index that excludes too much. A partial GIN index with WHERE is_published = true is fast for the catalog query but invisible to a query that needs to search unpublished rows. The fix is to keep the within-tenant search going against the full index (no partial predicate) and use the partial index only for the cross-tenant code path. If the planner picks the wrong one, an explicit index hint via a per-query setting or a function wrapper resolves it without forcing a global change.
The fourth is the materialized view that drifts because nothing refreshes it. The pattern that survives is a scheduled job that refreshes on a cadence sized to the freshness budget — for a leaderboard updated daily, an hourly refresh is fine; for a catalog the buyer searches in real time, the materialized view is the wrong tool and you want the live FTS query path with a partial index instead. The refresh job logs its duration on every run, and the alert rule is "refresh took longer than the cadence" — once that fires, the view is structurally over budget and either the source query needs a rework or the freshness budget needs a conversation.
The fifth is the external-index drift. Meilisearch or Typesense lag behind Postgres by however long the indexer queue is. For most catalog UIs that is fine; for "I just unpublished my listing, why is it still searchable" it is a support ticket. The mitigation is a two-write pattern at the boundary: the API endpoint that unpublishes a row also enqueues a high-priority reindex job and waits for it briefly, with a fallback that surfaces "your listing is being removed from search" to the user instead of pretending it is already gone.
The sixth is the IDOR through search filters. A naive search endpoint takes a tenant_id filter from the request body and trusts it. A bored attacker iterates tenant IDs and harvests another tenant's data through the search path even though every other endpoint is locked down. The fix is the rule above: the search handler builds the tenant filter from the authenticated session, not from the request body, and the server-side filter is appended to (not replaced by) any client-supplied filter. Pen tests find this category constantly because search is the obvious soft target on an otherwise hardened API.
What this looks like in production
At BFEAI the multi-tenant model is per-organization, and the read paths split cleanly into "things scoped to your org" and "things that are public across all orgs." The default RLS posture forces tenant isolation on every table that holds org-private data. The cross-tenant paths — the model catalog, the public template gallery, the leaderboard for the public benchmarks — each go through a SECURITY DEFINER function with its search_path pinned, and each function names the visibility rules it enforces directly in the body. The result is that a security reviewer can list every cross-tenant read path with a single \df+ * in psql, filter by Security showing definer, and read the body of each one.
The search backend split is pragmatic. Postgres FTS handles every search that returns under a few thousand candidate rows and does not need typo tolerance — that is most of the in-app search. Meilisearch handles the cross-tenant catalog because the catalog UI needs typo tolerance and faceted filtering that Postgres FTS does not give for free. The indexer is the boundary: it reads from the same catalog view the SECURITY DEFINER function reads from, so a row that is invisible to the live search is invisible to Meilisearch within one reindex cycle. The one rule that has saved several incidents is that the indexer does not read from the raw table with a service-role connection — it reads from the view, the view enforces the visibility rules, and the indexer just mirrors what the view returns.
The materialized view pattern is reserved for things that genuinely benefit from snapshotting. The "trending templates this week" widget is a materialized view refreshed hourly. The leaderboard for the public benchmarks is a materialized view refreshed every fifteen minutes. The catalog itself is not — buyers expect their unpublish to take effect now, and the latency of a refresh cycle is too long for that expectation. The materialized views all have their UNIQUE index in the migration that creates them, because Postgres rejects REFRESH CONCURRENTLY without it and the first time you discover that should not be in production.
The alert rules are short. The first is "RLS bypass detected in the application logs" — the application logs every connection that opens with set role to a non-tenant role, and the alert fires if the count is non-zero for any connection that ran a SELECT against a tenant table outside the SECURITY DEFINER path. The second is "indexer queue depth above threshold" — Meilisearch reindex falling behind is a precursor to the "I unpublished this hours ago" support ticket. The third is "materialized view refresh exceeded its cadence" — when the refresh starts taking longer than the schedule between refreshes, the view is on borrowed time and the source query needs attention.
The dashboard a CTO actually wants for this slice of the system is three numbers: connections opened as a tenant role in the last hour vs as the service role, indexer queue depth, and time since last successful refresh per materialized view. Anything else is detail. Those numbers are boring in normal operation, and any one of them moving is a real signal that the cross-tenant read path is drifting away from the discipline the rest of the system depends on.
What to watch in your own implementation
Open psql and run \d+ on a few of your tenant-scoped tables. Confirm RLS is enabled. Confirm the table is forced subject to RLS (the docs note table owners bypass by default unless you force them subject). If either is off on a table you thought was protected, that is the bug — every cross-tenant read on that table has been going through the default-allow path, not your policies.
Then grep your application code for connection strings. Any string that references a service-role credential is a candidate for review. The question for each one is not "do we use this safely" — it is "is there a code path on this connection that runs against a tenant table at all." If the answer is yes for anything outside a migration runner or a batch job, that path has been bypassing RLS and the catalog/search/leaderboard query is the most likely offender.
Next, list every SECURITY DEFINER function in your schema. In psql, \df+ * shows them with their security mode. For each one, confirm the SET search_path clause exists and ends with pg_temp. Confirm the function body does not call out to user-supplied SQL strings or dynamic SQL constructed from request data. Confirm the EXECUTE grant is scoped to the application role, not PUBLIC. Those three checks together close off the most common SECURITY DEFINER misuse patterns.
Finally, if you have an external search backend, trace one row from a write in your app to a hit in the external index. The path should pass through the same visibility rules as a live search. If the indexer connects directly to the raw table with a service-role credential and copies rows without filtering, you have a leak waiting for someone to notice. The fix is the indexer pattern above: read from a view or function that respects the visibility rules, and let the external index inherit them by construction. That is the property that lets you add Meilisearch or Typesense to a multi-tenant Postgres app without weakening the isolation guarantee the rest of the system depends on.
Outcomes you should expect
What this delivers
- Catalog and leaderboard queries that must read across tenants stop tripping the tenant-isolation guardrails the rest of the app depends on.
- Search latency on cross-tenant reads stays sub-100ms at a few million rows because the index is sized for the unscoped path, not retrofitted to it.
- A single audited code path owns every cross-tenant read, so a security review can answer 'where do tenants see each other's data' in one grep.
- Adding a second search backend (Meilisearch or Typesense) does not weaken Postgres tenant isolation, because the indexer respects the same visibility rules at write time.
Primary sources
By the numbers
All permissive RLS policies applicable to a query are combined together using the Boolean OR operator, while restrictive policies are combined using AND — and the two sets are then combined with AND.
Superusers and roles with the BYPASSRLS attribute always bypass the row security system, and table owners normally bypass RLS unless the table has ALTER TABLE ... FORCE ROW LEVEL SECURITY set.
Policy expressions are run with the privileges of the user running the query, although security-definer functions can be used to access data not available to the calling user.
A SECURITY DEFINER function is executed with the privileges of the user that owns it, and for security its search_path should be set to exclude any schemas writable by untrusted users.
GIN indexes are the preferred text search index type in Postgres; they store only the lexemes of tsvector values and contain an index entry for each word with a compressed list of matching locations.
REFRESH MATERIALIZED VIEW CONCURRENTLY allows the refresh to run without locking out concurrent selects, but requires at least one UNIQUE index on the materialized view that uses only column names and includes all rows.
Supabase recommends storing the tsvector in a generated column so the index updates automatically when source columns change, and creating a GIN index on that column for fast retrieval.
Live in production today
The same engineering, shipped in production at BFEAI.
I'm co-founder & CTO of Be Found Everywhere (BFEAI), a 7-app AI SaaS platform running today. The work I deliver for clients is the work I do every week on my own platform.
7
Production apps
200K+
Keywords generated
1,500+
AI scans run
7,000+
Sites automated
Common questions
What buyers ask before reaching out
Why not just disable RLS for the search query?
Disabling RLS works once and rots fast. The next engineer who copies the search query for a different feature inherits the bypass and ships a tenant leak. The pattern that survives is to keep RLS on, define an explicit cross-tenant read path through a SECURITY DEFINER function or a separate read policy, and make that path the single audited entry point for cross-tenant reads.
When should I use RESTRICTIVE policies instead of just PERMISSIVE ones?
PERMISSIVE policies OR together — any one passing grants access. RESTRICTIVE policies AND together with the permissive set, so they let you add a non-bypassable guardrail like 'hide if soft-deleted' or 'hide if the tenant is suspended.' Use RESTRICTIVE when you want a rule that cannot be relaxed by adding another policy later.
Is a SECURITY DEFINER function safe?
It is safe when the function body cannot be subverted and its search_path is locked down. Postgres documents that SECURITY DEFINER runs with the owner's privileges, so a missing search_path lets an attacker shadow a table or function the owner trusts. Set search_path explicitly with pg_temp last, mark the function STABLE or VOLATILE intentionally, and treat it as a privileged code path during review.
Do I need a separate search backend like Meilisearch or Typesense?
You need one when Postgres FTS stops scaling for your query shape — typically when you need typo tolerance, faceting across hundreds of attributes, or ranking that depends on user signals. Below that point the operational cost of a second system rarely beats a well-indexed tsvector column. The deciding question is not raw QPS; it is whether your search UX requires features Postgres FTS does not have.
How do I keep a Meilisearch or Typesense index tenant-aware?
Two layers. At write time, the indexer reads from a view or function that respects RLS, so private documents do not reach the external index. At read time, every query is filtered by tenant_id (or by an allowed-tenants array for cross-tenant searches), enforced by a server-side wrapper that constructs the filter from the authenticated session rather than trusting the client. Both layers matter — either alone leaks.
Why use a materialized view instead of querying the live table?
Two reasons. First, the materialized view can have its own indexes shaped for the cross-tenant query, so you do not pay the cost on every write to the live table. Second, the materialized view is a snapshot, so a slow rebuild does not block writes on the source. The tradeoff is staleness — refreshing CONCURRENTLY keeps reads available but still has to run, so you pick a cadence that matches your freshness budget.
What goes wrong with REFRESH MATERIALIZED VIEW in production?
Without CONCURRENTLY, the refresh takes an exclusive lock and every read of the view blocks until it finishes. With CONCURRENTLY, the refresh requires a unique index that covers every row of the view, and only one refresh can run against the view at a time. Teams that skip the unique-index requirement discover it during the first production refresh and have to ship a follow-up migration under pressure.
Does scoping the GIN index by tenant_id help?
A partial GIN index per high-traffic tenant can speed within-tenant search materially, but the carrying cost is one extra index per tenant which gets unmanageable past a few. The default that scales is one GIN index on the tsvector column for the whole table, with the tenant_id predicate enforced by RLS, and a separate cross-tenant index (or materialized view) for the catalog query. Reserve per-tenant partial indexes for tenants whose query mix justifies them.
Ready to see if this is a fit?
A 15-minute call. No deck, no slides. We talk about what you're shipping and where engineering is the bottleneck. Either way, you walk away with a senior engineer's read on your situation.