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 LOCALbehavior inside pooled transactions and forCREATE POLICY ... FOR SELECT/INSERTsplit policies. - pgvector 0.5.0+ (0.7+ if you also use
halfvec);CREATE EXTENSION vector;already run in the target database. - A
tenant_id(orowner_uuid) column of typeuuidon the embeddings table, declaredNOT NULL. - The application role is not a superuser and does not hold
BYPASSRLS— verify withSELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;. - A connection pooler (PgBouncer in transaction mode, or SQLAlchemy
QueuePool) — this is what makesSET LOCALmandatory rather than optional. -
work_memandshared_bufferssized for your per-tenant candidate set (see the parameter table below).
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.
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.
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:
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:
coalesce(
current_setting('app.current_tenant_id', true),
'00000000-0000-0000-0000-000000000000'
)::uuidAn unset current_setting() in the raw form makes the USING clause evaluate to NULL → FALSE, 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:
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):
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.
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:
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; ifNULL, your pooler droppedSET SESSION— switch toSET LOCALinside an explicit transaction. - Cross-tenant rows leak in. The connecting role is a superuser or holds
BYPASSRLS, or the table hasENABLEbut notFORCE ROW LEVEL SECURITY. Re-checkpg_rolesand run theFORCEstatement from Step 1. Seq Scandespite an HNSW/IVFFlat index. The planner cannot align the RLS predicate with the index. Confirm the partial indexWHERE tenant_id IS NOT NULLexists, runEXPLAIN (ANALYZE, BUFFERS)withSET LOCAL enable_seqscan = offto isolate the cause, and raisehnsw.ef_search/ivfflat.probesto 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_searchto80–120, or if the metric is amplifying the loss, revisit your distance choice and vector data type selection. NULLtenant embeddings vanish entirely.NULL = current_setting(...)isNULL→FALSE, so orphaned rows are invisible to every tenant. Enforcetenant_id NOT NULLand add aWITH CHECKpolicy 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.
Related
- How to Choose Between Cosine and L2 for Semantic Search — pick the metric before scoping the index.
- Calculating pgvector Storage Requirements for 10M Embeddings — how per-tenant partitioning multiplies index footprint.
- Tuning IVFFlat Lists for High-Throughput Similarity Search — set
probesto recover recall lost to the RLS post-filter. - Up: Security Boundaries for Vector Data