Securing pgvector Tables with Row-Level Security

This page shows how to enforce PostgreSQL Row-Level Security (RLS) on a shared pgvector table so a similarity search can only ever return the calling tenant’s embeddings. It covers the exact policy, session-binding, and index arrangement that keep tenant isolation deterministic without collapsing your approximate nearest neighbor (ANN) scan into a sequential fallback.

Up: Security Boundaries for Vector Data

The hard part is not writing the policy — it is that an ANN index does not understand a tenant boundary. An hnsw or ivfflat scan walks its graph or probes its lists and hands the executor whatever is geometrically closest; RLS then filters those candidates after the fact. Get the ordering wrong and you either leak another customer’s rows or spend your entire recall budget on candidates the policy will discard. The procedure below fixes both failure modes. It builds on the shared-table isolation model introduced in the security boundaries for vector data overview and assumes the storage and execution mechanics described in pgvector Architecture & Vector Fundamentals.

Prerequisites

Confirm the following before applying any policy in production:

  • PostgreSQL 15 or newer — required for stable SET LOCAL behavior inside pooled transactions and for CREATE POLICY ... FOR SELECT/INSERT split policies.
  • pgvector 0.5.0+ (0.7+ if you also use halfvec); CREATE EXTENSION vector; already run in the target database.
  • A tenant_id (or owner_uuid) column of type uuid on the embeddings table, declared NOT NULL.
  • The application role is not a superuser and does not hold BYPASSRLS — verify with SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;.
  • A connection pooler (PgBouncer in transaction mode, or SQLAlchemy QueuePool) — this is what makes SET LOCAL mandatory rather than optional.
  • work_mem and shared_buffers sized for your per-tenant candidate set (see the parameter table below).
One RLS-scoped pgvector query, in transaction order — and the BYPASSRLS lane that skips the policy Reading top to bottom as a single transaction: the pooled app connection runs BEGIN and SET LOCAL app.current_tenant_id, then sends the ORDER BY embedding distance LIMIT k query to the query planner. The planner pushes the tenant_id predicate into the partial HNSW index, whose ANN walk visits only that tenant's rows and returns candidate rows to the RLS USING filter. The USING clause re-checks tenant_id equals current_setting on every candidate, then top-k tenant-scoped rows return to the app, and COMMIT discards the setting on release back to the pool. In parallel a superuser or BYPASSRLS role ignores every policy and sees all rows, never reaching the USING check. App pooled connection Query planner predicate push-down Partial HNSW index WHERE tenant_id NOT NULL RLS USING filter per-candidate re-check Superuser / BYPASSRLS BEGIN · SET LOCAL app.current_tenant_id ORDER BY embedding <=> q LIMIT k push tenant_id predicate ANN walk visits only tenant rows candidate rows USING re-check: tenant_id = current_setting() top-k tenant-scoped rows COMMIT setting discarded on release ignores every policy All rows visible policy skipped Solid arrows are one ordered transaction, top to bottom; the red lane is a role that never reaches the USING check.

Step-by-Step: Enforcing RLS Without an Index Fallback

1. Enable and force RLS on the table

Enabling RLS is not enough on its own — the table owner still bypasses policies unless you also FORCE it. This is a common silent leak during migrations run as the owning role.

SQL
ALTER TABLE vector_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE vector_embeddings FORCE ROW LEVEL SECURITY;

2. Define split policies for read and write

Use a narrow USING clause for reads and a separate WITH CHECK clause for writes. Splitting them keeps SELECT evaluation cheap (it is on the hot path of every similarity search) while still rejecting cross-tenant INSERT/UPDATE.

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

CREATE POLICY tenant_write ON vector_embeddings
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);

The vector column itself needs no special syntax — policies operate on the ordinary relational tenant_id discriminator, never on the embedding.

3. Bind the tenant context per transaction

current_setting('app.current_tenant_id') must be populated for every transaction and must never survive connection release back to the pool. Use SET LOCAL inside an explicit transaction so the value is discarded at COMMIT/ROLLBACK:

PYTHON
from sqlalchemy import text

def execute_vector_query(engine, tenant_uuid, query_vector, limit=10):
    with engine.begin() as conn:  # begin() => one transaction, auto-commit/rollback
        # Bound to THIS transaction only; cleared on release back to the pool.
        conn.execute(
            text("SET LOCAL app.current_tenant_id = :tid"),
            {"tid": str(tenant_uuid)},
        )
        result = conn.execute(
            text(
                """
                SELECT id, metadata, embedding
                FROM vector_embeddings
                ORDER BY embedding <=> :q_vec
                LIMIT :lim
                """
            ),
            {"q_vec": query_vector, "lim": limit},
        )
        return result.fetchall()

Never use SET SESSION (or a bare SET) behind PgBouncer transaction pooling: the value would bleed into the next tenant that borrows the same backend connection. If the setting can be unset, guard it so an absent context fails closed to a sentinel rather than raising:

SQL
coalesce(
  current_setting('app.current_tenant_id', true),
  '00000000-0000-0000-0000-000000000000'
)::uuid

An unset current_setting() in the raw form makes the USING clause evaluate to NULLFALSE, which silently returns zero rows — a stalled pipeline that looks like “no results” rather than an error.

4. Build a tenant-scoped ANN index that survives the filter

To keep the planner on the ANN path, the tenant predicate has to be pushed down alongside the vector operator. A partial hnsw index scoped to non-null tenants keeps the graph usable for the ORDER BY embedding <=> :q LIMIT k pattern:

SQL
CREATE INDEX idx_tenant_hnsw_cosine ON vector_embeddings
USING hnsw (embedding vector_cosine_ops)
WHERE tenant_id IS NOT NULL;

Match the operator class to your distance metric — vector_cosine_ops for <=>, vector_l2_ops for <->. If you have not fixed the metric yet, settle it first using the cosine vs L2 selection guide, because the choice changes how much recall the post-filter step costs you. For the full production build recipe, follow the step-by-step HNSW index creation for production workloads procedure.

5. Raise the candidate pool to offset post-filter loss

Because RLS removes rows after the ANN scan returns candidates, effective recall per tenant drops. Compensate by widening the search frontier at query time (still inside the same transaction):

SQL
SET LOCAL hnsw.ef_search = 100;   -- or: SET LOCAL ivfflat.probes = 10;

Parameter Reference

Parameter Type Default Production recommendation Notes
row_security GUC (enum) on on — set explicitly in connection init If a client sets it off, an owner/BYPASSRLS role sees all tenants. Pin it.
app.current_tenant_id custom GUC (text→uuid) unset Set via SET LOCAL every transaction Unset ⇒ USING resolves to FALSE ⇒ zero rows. Never SET SESSION behind a pooler.
hnsw.ef_search integer 40 80–120 under RLS Higher value restores recall lost to post-filter row removal.
ivfflat.probes integer 1 8–16 under RLS Same intent for ivfflat; scale with lists. See tuning ivfflat lists.
hnsw.m integer (build) 16 16–32 Keep low for multi-tenant tables; higher m inflates graph size and concurrent build cost.
work_mem memory 4MB 32–128MB per connection Prevents disk spills when RLS forces a sort over metadata columns; size against max concurrency.
enable_seqscan boolean on on in prod; off only for diagnosis Temporarily off to prove whether the planner can use the ANN index under the policy.

Verification

Prove isolation with a synthetic cross-tenant probe: set the context to tenant A, then query rows you know belong to tenant B. The result set must be empty.

SQL
BEGIN;
SET LOCAL app.current_tenant_id = '11111111-1111-1111-1111-111111111111';

-- Should return 0 rows: tenant A cannot see tenant B's embeddings.
SELECT count(*)
FROM vector_embeddings
WHERE tenant_id = '22222222-2222-2222-2222-222222222222';
ROLLBACK;

Then confirm the ANN index — not a sequential scan — is servicing the tenant-scoped similarity query:

SQL
BEGIN;
SET LOCAL app.current_tenant_id = '11111111-1111-1111-1111-111111111111';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vector_embeddings
ORDER BY embedding <=> '[0.1, 0.2, ...]'
LIMIT 10;
ROLLBACK;

A healthy plan shows an Index Scan using idx_tenant_hnsw_cosine with a small Rows Removed by Filter. A Seq Scan or a Rows Removed by Filter above ~90% of scanned rows means the tenant predicate is not being pushed into the index.

Troubleshooting

  • Query returns zero rows but data exists. The tenant context is unset or malformed. Check SELECT current_setting('app.current_tenant_id', true); inside the transaction; if NULL, your pooler dropped SET SESSION — switch to SET LOCAL inside an explicit transaction.
  • Cross-tenant rows leak in. The connecting role is a superuser or holds BYPASSRLS, or the table has ENABLE but not FORCE ROW LEVEL SECURITY. Re-check pg_roles and run the FORCE statement from Step 1.
  • Seq Scan despite an HNSW/IVFFlat index. The planner cannot align the RLS predicate with the index. Confirm the partial index WHERE tenant_id IS NOT NULL exists, run EXPLAIN (ANALYZE, BUFFERS) with SET LOCAL enable_seqscan = off to isolate the cause, and raise hnsw.ef_search / ivfflat.probes to enlarge the candidate pool.
  • Recall drops after enabling RLS. Expected — the post-filter discards in-tenant candidates that fell outside the frontier. Raise hnsw.ef_search to 80–120, or if the metric is amplifying the loss, revisit your distance choice and vector data type selection.
  • NULL tenant embeddings vanish entirely. NULL = current_setting(...) is NULLFALSE, so orphaned rows are invisible to every tenant. Enforce tenant_id NOT NULL and add a WITH CHECK policy so unscoped inserts are rejected at write time rather than hidden at read time.

FAQ

Q: Does RLS slow down every pgvector similarity search?

The policy predicate itself is cheap; the real cost is recall loss from post-index filtering. With a tenant-scoped partial index and a modestly raised hnsw.ef_search, latency stays within a few percent of an unfiltered scan. The failure case is a sequential-scan fallback, which the verification EXPLAIN above is designed to catch.

Q: RLS or a separate schema/partition per tenant?

Shared-table RLS is the densest and cheapest layout — one index, one VACUUM target. Move to partitioning by tenant_id when a single tenant exceeds roughly 10M rows: partition pruning happens before RLS evaluation, and each partition carries its own ANN index for parallel, predictable scans. The trade-offs are compared in the security boundaries for vector data overview.

Q: How do I audit who queried which embeddings?

pgaudit logs the policy-scoped statement but not the similarity results. Add an application-level hook that records tenant_id, a hash of the query vector, the returned IDs, and a timestamp, so provenance survives outside the database.