Security Boundaries for Vector Data

Embeddings stored inside PostgreSQL inherit every relational security primitive — roles, schemas, row-level policies, audit logging — but they also introduce failure modes those primitives were never designed for. An approximate nearest neighbor (ANN) index does not “see” a tenant boundary; it walks a graph or probes a set of lists and hands the executor whatever candidates are closest, leaving row visibility to be reconciled afterward. Get that reconciliation wrong and a multi-tenant semantic search silently returns another customer’s documents, or leaks cluster density through distance-score distributions and query timing. This guide shows how to draw hard boundaries around vector data: which isolation model to choose, how policy evaluation interacts with hnsw and ivfflat scans, and how to prove the boundary holds under load. It builds on the pgvector Architecture & Vector Fundamentals foundation, because index structure, storage layout, and query execution paths all determine where a security predicate can actually be enforced.

How row-level security scopes a similarity search — and how a BYPASSRLS role sidesteps it An application query binds a tenant with SET LOCAL app.tenant_id on the connection, then reaches the RLS policy check tenant_id equals current_setting. On a match the ANN scan runs over tenant rows and returns tenant-scoped top-k results; on no match the rows are hidden. In parallel, a superuser or BYPASSRLS role ignores every policy and sees all rows, bypassing the boundary. App query tenant session SET LOCAL app.tenant_id bound to the connection RLS policy: tenant_id match? match no match ANN scan over tenant rows Top-k results tenant-scoped Rows hidden filtered by policy Superuser / BYPASSRLS role ignores every policy All rows visible boundary bypassed

Architectural Divergence & Trade-offs

There are three durable ways to isolate embeddings for different tenants or data-classification tiers, and they differ mainly in where the boundary is enforced and what it costs the ANN index.

Shared table with row-level security (RLS). One document_embeddings table holds every tenant’s rows, discriminated by a tenant_id column, with an RLS policy filtering on a session variable. This is the densest layout and the cheapest to operate — a single index, a single VACUUM target — but it is also the model where the ANN-versus-visibility reconciliation problem is sharpest: the index is built over all tenants, so a query walks foreign rows before they are filtered out, spending recall budget on candidates it will discard.

Schema-per-tenant. Each tenant owns a schema (tenant_alpha, tenant_beta) with its own document_embeddings table and its own index. Isolation becomes a GRANT/search_path question rather than a per-row predicate, and the index contains only that tenant’s vectors, so recall is not diluted. The cost is multiplicity: hundreds of schemas mean hundreds of indexes to build, tune, and vacuum, and DDL migrations must fan out across all of them.

Partition-per-tenant (or per-tier). A single logical table is declaratively partitioned by tenant_id (or by retention/classification tier). PostgreSQL prunes irrelevant partitions before RLS is even evaluated, and each partition carries its own ANN index, so scans stay tenant-local while DDL stays centralized. This is the pragmatic middle ground once any single tenant exceeds a few million rows.

Isolation model Boundary enforced at ANN index scope Recall dilution Operational cost Best fit
Shared table + RLS Per-row policy All tenants in one index High (foreign candidates walked then discarded) Lowest — one index, one vacuum Many small tenants, low per-tenant row counts
Schema-per-tenant GRANT + search_path One index per tenant None High — N indexes, fan-out DDL Few large tenants, strict compliance separation
Partition-per-tenant Partition pruning + RLS One index per partition None (pruned before scan) Medium — centralized DDL, N indexes Skewed tenants, some exceeding ~5M rows

Isolation cost is never only about correctness — it is also about how much of the index a query is allowed to ignore. Under the shared-table model the effective candidate pool per tenant shrinks, which means hnsw.ef_search and ivfflat.probes must climb to hold recall steady; those same knobs are covered from the pure-performance angle in Optimizing m and ef_construction Parameters. Partition and schema models keep the index tenant-local and side-step the dilution entirely, which is why they scale better as any single tenant grows. Storage math also shifts with the model — N per-tenant indexes multiply the index-to-heap ratio, so revisit the pgvector Storage Overhead Analysis before committing to schema- or partition-per-tenant at scale.

The three vector-isolation models plotted by recall dilution against operational cost Shared table with row-level security sits at high recall dilution and low operational cost; its boundary is a per-row policy and its ANN index spans all tenants. Partition-per-tenant sits at no dilution and medium cost; its boundary is partition pruning plus RLS with one index per partition. Schema-per-tenant sits at no dilution and high cost; its boundary is GRANT plus search_path with one index per tenant. Cost rises as isolation hardens, while only the shared-table model pays a recall-dilution penalty. Recall dilution → none high Operational cost → low high Schema-per-tenant boundary · GRANT + search_path index · one per tenant Partition-per-tenant boundary · pruning + RLS index · one per partition Shared table + RLS boundary · per-row policy index · all tenants, one

Parameter Space & Diagnostic Workflow

Security boundaries for vectors are governed by a small set of GUCs and role attributes that most performance guides ignore. The defaults are permissive; production values are not.

Knob Scope Default Production recommendation Notes
row_security Session GUC on on, set explicitly in connection init A client can SET row_security = off; if the role also has BYPASSRLS this disables policies. Pin it on.
Role BYPASSRLS Role attribute absent Never on application roles Superusers and BYPASSRLS roles ignore every policy. Application pools must use a plain, unprivileged role.
search_path Session GUC "$user", public Explicit per-connection, reset on release Prevents schema-traversal into another tenant’s objects. Enforce at the pooler (server_reset_query).
hnsw.ef_search Session GUC 40 100200 under shared-table RLS Foreign candidates consume the pool; raise to recover recall lost to post-filtering.
ivfflat.probes Session GUC 1 1040 under shared-table RLS Same dilution effect; too-low probes plus RLS silently collapses recall.
pgaudit.log Session/DB GUC unset 'read, write, ddl' Captures who read which vector rows; required for GDPR/HIPAA/SOC 2 evidence.
work_mem Session GUC 4MB 32256MB for policy-heavy sorts RLS predicates on metadata columns can trigger sorts that spill to disk.

The first diagnostic question is always: is the policy actually being evaluated, and where in the plan? A predicate applied as a late Filter after a heap fetch is far weaker (and slower) than one pushed into the index scan.

SQL
-- Confirm RLS is enabled and which policies exist on the vector table
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class
WHERE relname = 'document_embeddings';

SELECT polname, polcmd, pg_get_expr(polqual, polrelid) AS using_expr
FROM pg_policy
WHERE polrelid = 'document_embeddings'::regclass;

-- Confirm the connecting role cannot sidestep policies
SELECT rolname, rolsuper, rolbypassrls
FROM pg_roles
WHERE rolname = current_user;

Then inspect the plan with the tenant context bound, watching for the policy predicate landing on the Index Scan node rather than as a post-filter that removes most of what it scanned:

SQL
SET app.tenant_id = '00000000-0000-0000-0000-000000000001';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM document_embeddings
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;
-- Healthy:   Index Scan ... with tenant_id predicate; low "Rows Removed by Filter"
-- Degraded:  Seq Scan fallback, or "Rows Removed by Filter" > 90% of rows scanned

Step-by-Step Implementation

The following sequence stands up a shared-table boundary end to end. For schema- or partition-per-tenant, steps 3–4 move to per-schema/per-partition objects but the role and session-binding steps are identical.

1. Create a least-privilege application role. The pool must never authenticate as the table owner or the extension owner.

SQL
CREATE ROLE app_vector LOGIN PASSWORD 'REPLACE_ME';
REVOKE ALL ON SCHEMA public FROM app_vector;
GRANT USAGE ON SCHEMA app_data TO app_vector;
GRANT SELECT, INSERT ON app_data.document_embeddings TO app_vector;
-- Explicitly *not* granted: BYPASSRLS, SUPERUSER, table ownership

2. Force RLS so even the table owner is subject to policies. Plain ENABLE exempts the owner; FORCE closes that gap for migrations run as the owner.

SQL
ALTER TABLE app_data.document_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE app_data.document_embeddings FORCE ROW LEVEL SECURITY;

3. Constrain the discriminator column. A nullable tenant_id is a leak waiting to happen (see Failure Modes). Make it NOT NULL.

SQL
ALTER TABLE app_data.document_embeddings
  ALTER COLUMN tenant_id SET NOT NULL;

4. Define split USING / WITH CHECK policies. USING governs which rows are visible to reads; WITH CHECK governs which rows may be written. Splitting them keeps read-path evaluation cheap while still preventing cross-tenant inserts.

SQL
CREATE POLICY tenant_read ON app_data.document_embeddings
  FOR SELECT
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY tenant_write ON app_data.document_embeddings
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

5. Bind the tenant context per transaction, never per pool. With SET LOCAL the setting is scoped to the transaction and rolled back automatically when the pooled connection is returned, so it can never leak to the next borrower.

PYTHON
from sqlalchemy import text

def tenant_search(engine, tenant_uuid, query_vector, k=10):
    with engine.begin() as conn:  # begin() => transactional scope
        conn.execute(
            text("SELECT set_config('app.tenant_id', :tid, true)"),
            {"tid": str(tenant_uuid)},  # is_local=true => SET LOCAL semantics
        )
        rows = conn.execute(
            text(
                "SELECT id, metadata "
                "FROM app_data.document_embeddings "
                "ORDER BY embedding <=> :qv "
                "LIMIT :k"
            ),
            {"qv": query_vector, "k": k},
        ).fetchall()
    return rows

6. Harden the ingestion boundary. Treat vectorization as a trusted gate: validate dimensionality and tenant consistency before insert, and normalize on the way in so distance scores cannot betray magnitude. Vectors should arrive unit-normalized per the procedure in normalizing embeddings before pgvector insertion, and the metric that normalization assumes is settled in Cosine vs L2 Distance Metrics.

PYTHON
def guard_row(row, expected_dim, tenant_uuid):
    vec = row["embedding"]
    if len(vec) != expected_dim:
        raise ValueError(f"dim {len(vec)} != expected {expected_dim}")
    if row["tenant_id"] != tenant_uuid:
        raise PermissionError("row tenant_id does not match pipeline context")
    return row

Validation & Recall Testing

A boundary you have not tried to break is a boundary you do not have. Two checks matter: a leak test (does the policy hold against a hostile query?) and a recall test (does isolation quietly cost accuracy?).

Cross-tenant leak test. Bind tenant A, run the search, and assert that not a single returned row belongs to tenant B. Run it as the unprivileged application role, not as a superuser, or the test proves nothing.

PYTHON
def assert_no_leak(engine, tenant_a, tenant_b, query_vector):
    with engine.begin() as conn:
        conn.execute(
            text("SELECT set_config('app.tenant_id', :tid, true)"),
            {"tid": str(tenant_a)},
        )
        leaked = conn.execute(
            text(
                "SELECT count(*) FROM app_data.document_embeddings "
                "WHERE tenant_id = :other"
            ),
            {"other": str(tenant_b)},
        ).scalar()
    assert leaked == 0, f"LEAK: {leaked} rows of {tenant_b} visible to {tenant_a}"

Recall-under-isolation test. Compare the RLS-scoped ANN result against a tenant-scoped exact (sequential-scan) ground truth. If recall drops after enabling RLS, the shared index is diluting the candidate pool and you must raise hnsw.ef_search / ivfflat.probes or move to a partitioned layout.

PYTHON
def recall_at_k(conn, tenant_uuid, query_vector, k=10):
    conn.execute(text("SELECT set_config('app.tenant_id', :t, true)"),
                 {"t": str(tenant_uuid)})
    approx = {r[0] for r in conn.execute(text(
        "SELECT id FROM app_data.document_embeddings "
        "ORDER BY embedding <=> :q LIMIT :k"), {"q": query_vector, "k": k})}
    conn.execute(text("SET LOCAL enable_indexscan = off"))
    conn.execute(text("SET LOCAL enable_bitmapscan = off"))
    exact = {r[0] for r in conn.execute(text(
        "SELECT id FROM app_data.document_embeddings "
        "ORDER BY embedding <=> :q LIMIT :k"), {"q": query_vector, "k": k})}
    return len(approx & exact) / float(k)

On the SQL side, EXPLAIN (ANALYZE, BUFFERS) is the arbiter: a Seq Scan where you expected an Index Scan, or a high Rows Removed by Filter, means the policy is being enforced as a post-filter and both security and latency suffer. Detailed plan-reading patterns for policy-aware scans are worked through in Securing pgvector tables with row-level security.

Failure Modes & Gotchas

  • BYPASSRLS / superuser pools. The single most common cross-tenant breach: an application connects with a role that carries BYPASSRLS (or a client issues SET row_security = off) and every policy evaporates. Audit pg_roles.rolbypassrls for every login role in the pool and pin row_security = on in the connection init.
  • Nullable discriminator leaks. NULL = current_setting('app.tenant_id') evaluates to NULL, which the policy treats as not visible — so orphaned rows with a NULL tenant_id silently vanish from every tenant and become undeletable through the application. Enforce NOT NULL and reject unscoped inserts with WITH CHECK.
  • Sequential-scan fallback. When the policy predicate is not sargable, or statistics are stale, the planner drops the ANN index and sequentially scans the whole shared table, filtering per row. Latency spikes and the “tenant-scoped” search now touches every tenant’s pages. Catch it with the Seq Scan signature in EXPLAIN and by watching seq_scan on pg_stat_user_tables.
  • Recall collapse under dilution. In the shared-table model the ANN pool is global; a small tenant’s true neighbors may sit outside the default ef_search/probes window because foreign candidates crowd them out. Recall looks fine in aggregate and terrible per small tenant. Test recall per tenant, not globally.
  • Timing and score side-channels. Even when rows are correctly hidden, the cost of hiding them (heap fetches for discarded candidates) and the returned distance distribution can leak the existence and density of other tenants’ data. Unit-normalize vectors so scores are comparable, cap result sets with an explicit LIMIT, and prefer partition pruning so foreign rows are never scanned in the first place.
  • Pool-scoped context bleed. Setting app.tenant_id with a plain SET (not SET LOCAL / set_config(..., true)) leaves the value on the physical connection after it returns to the pool, so the next borrower inherits the previous tenant’s scope. Always bind inside a transaction.

Monitoring & Alerting Hooks

Vector-data access must be observable and auditable to satisfy GDPR, HIPAA, and SOC 2. Combine PostgreSQL’s native statistics views with pgaudit for a defensible trail.

INI
# postgresql.conf — audit reads and writes against vector tables
pgaudit.log = 'read, write, ddl'
pgaudit.log_relation = on
log_statement = 'mod'
log_duration = on

Watch for the tell-tale signatures of a failing boundary — sequential-scan spikes on the vector table (policy pushdown lost) and any login role that can bypass RLS:

SQL
-- Sequential-scan pressure on the vector table (Prometheus-scrapable)
SELECT relname,
       seq_scan,
       idx_scan,
       n_live_tup
FROM pg_stat_user_tables
WHERE relname = 'document_embeddings';

-- Alert: any non-system login role that ignores row security
SELECT rolname
FROM pg_roles
WHERE rolcanlogin
  AND (rolbypassrls OR rolsuper)
  AND rolname NOT IN ('postgres');

Export both as gauges: seq_scan climbing on document_embeddings should page whoever owns the search path, and a non-empty second query should fail a deployment gate. For anomaly detection, fingerprint similarity queries (query-vector hash plus target partition) and alert on high-frequency probing of restricted partitions. These access signals sit naturally alongside the ingestion-side integrity checks described across the embedding ingestion pipeline engineering work, so drift in what is embedded and anomalies in who reads it land on the same dashboard.

FAQ

Does enabling RLS slow down pgvector similarity search?

It can, and the cause is usually pool dilution rather than the policy check itself. In a shared table the ANN index spans every tenant, so a scoped query still walks foreign candidates before the policy discards them — which both wastes recall budget and adds heap fetches. Confirm with EXPLAIN (ANALYZE, BUFFERS) that the tenant_id predicate lands on the index scan (not a late Filter), then either raise hnsw.ef_search / ivfflat.probes to restore recall or move to a partition-per-tenant layout so foreign rows are pruned before the scan.

Why did my RLS policy hide rows that should be visible?

The classic cause is a NULL in the discriminator column: NULL = current_setting('app.tenant_id') evaluates to NULL, which the policy treats as not-visible, so those rows disappear from every tenant. Enforce NOT NULL on tenant_id and add a WITH CHECK clause so unscoped rows can never be inserted. The second cause is an unset session variable — if app.tenant_id was never bound for the transaction, current_setting() errors or returns empty and matches nothing.

Can a connection-pooled application safely use current_setting() for tenancy?

Yes, but only with transaction-scoped binding. Use set_config('app.tenant_id', tid, true) or SET LOCAL inside an explicit transaction so the value is rolled back when the connection returns to the pool. A plain SET persists on the physical connection and the next borrower inherits the previous tenant’s scope — a cross-tenant leak that no policy can catch because the policy is being told the wrong tenant.

Which isolation model should I start with?

Start with shared-table RLS if you have many small tenants and low per-tenant row counts — it is the cheapest to operate and RLS enforces the boundary correctly as long as your roles cannot bypass it. Move to partition-per-tenant once any single tenant approaches a few million rows, so partition pruning keeps scans tenant-local and recall un-diluted. Reserve schema-per-tenant for a small number of large tenants that need hard compliance separation and independent DDL lifecycles.

How do I prove to an auditor that tenant data is isolated?

Combine three artifacts: pgaudit logs (pgaudit.log = 'read, write, ddl') showing every read against the vector table with the bound tenant_id; a CI leak test that connects as the unprivileged application role, scopes to tenant A, and asserts zero rows of tenant B are visible; and a role inventory query proving no application login role carries BYPASSRLS or SUPERUSER. Together they show the boundary is enforced, tested, and monitored rather than merely configured.

Up: pgvector Architecture & Vector Fundamentals