Skip to main content
Back to Blog
RAGPostgreSQLNext.jsMulti-TenancyJWTSecuritypgvectorGraphRAG

How to Build a Secure Multi-Tenant RAG Pipeline using PostgreSQL Row-Level Security, Next.js, and JWT Auth

Prevent sensitive data leakage in your B2B AI SaaS. Learn how to implement PostgreSQL Row-Level Security (RLS) for vector embeddings, extract tenant IDs from Next.js JWT payloads, and build isolated GraphRAG retrieval pipelines with Node.js and Express.

September 12, 202616 min readNiraj Kumar

Introduction

If you're building a B2B AI SaaS product in 2026, chances are you're shipping some flavor of Retrieval-Augmented Generation (RAG). Your customers upload contracts, support tickets, internal wikis, or compliance documents, and your product lets them "chat" with that data. It's a fantastic pitch — until you realize the blast radius of a single bug.

Picture this: Tenant A uploads a confidential M&A term sheet. Tenant B, a completely unrelated company using the same SaaS instance, asks your chatbot an innocuous question. Somewhere in your retrieval layer, a missing tenant_id filter causes a handful of Tenant A's embeddings to get pulled into Tenant B's context window. The LLM, being an LLM, happily summarizes what it was given. You've just caused a catastrophic data breach — and it didn't even require a hacker. It just required a junior engineer forgetting a WHERE clause on a Friday afternoon.

This is not a hypothetical. It's the single most common architectural failure in multi-tenant AI applications, and it's exactly why application-level isolation is not sufficient for RAG systems that touch sensitive enterprise data.

In this guide, we're going to build a genuinely secure multi-tenant RAG pipeline where tenant isolation is enforced at the database layer, not just in your application code. We'll cover:

  • Designing a JWT-based auth flow in Next.js that carries a verified tenant_id
  • Setting up PostgreSQL with pgvector and Row-Level Security (RLS) policies
  • Securely propagating the tenant context from Next.js to a Node.js/Express backend
  • Writing GraphRAG-style retrieval queries that are automatically scoped per tenant
  • Testing your isolation guarantees like an attacker would

By the end, you'll have a pipeline where a compromised query, a missing filter, or even a malicious prompt injection cannot leak cross-tenant data — because the database itself refuses to return rows it shouldn't.


Why "Just Filter by tenant_id in the Query" Isn't Enough

Most tutorials teach you to add a tenant_id column to your tables and remember to filter by it in every query. This works — until it doesn't. Here's why this pattern breaks down in real production systems:

  • Human error compounds over time. As your codebase grows, dozens of engineers touch the retrieval logic. One raw SQL query, one ORM shortcut, or one "quick fix" during an incident, and the filter gets dropped.
  • LLM-generated queries are a new attack surface. In 2026, many RAG pipelines let an LLM generate SQL or Cypher queries dynamically (text-to-SQL agents, GraphRAG traversal planners). If the LLM forgets to include the tenant filter — and LLMs do forget — you have an automated data leak.
  • Background jobs and admin tools often skip the "normal" code path. A data migration script, an analytics job, or an internal admin dashboard frequently bypasses your carefully filtered API layer and queries the database directly.
  • Caching layers can silently mix tenants. If your vector cache key doesn't include the tenant ID, you can serve cached embeddings from the wrong tenant entirely.

The fix isn't "be more careful." The fix is to make the database refuse to return rows that don't belong to the current tenant, regardless of what query was sent. That's precisely what PostgreSQL Row-Level Security was built for.


Architecture Overview

Here's the high-level flow we're building:

  1. User authenticates via your identity provider (Auth0, Clerk, or your own auth server) and receives a JWT containing a signed tenant_id claim.
  2. Next.js middleware verifies the JWT signature and extracts the tenant_id on every request — it never trusts a client-supplied header.
  3. Next.js forwards the verified tenant context to your Express backend as a short-lived, re-signed internal token (never the raw client JWT).
  4. Express middleware verifies the internal token and opens a PostgreSQL transaction, immediately setting a session variable (app.current_tenant_id) via SET LOCAL.
  5. PostgreSQL RLS policies on the documents, chunks, and graph_nodes tables automatically filter every query against that session variable — no WHERE tenant_id = ... required in application code.
  6. GraphRAG retrieval (vector similarity + graph traversal) runs against these RLS-protected tables, so the LLM only ever sees chunks and graph relationships belonging to the authenticated tenant.
[Browser] --JWT--> [Next.js Middleware] --Internal Token--> [Express API]
                                                                  |
                                                        SET LOCAL app.current_tenant_id
                                                                  |
                                                          [PostgreSQL + pgvector]
                                                          (RLS enforced on every table)

The key architectural principle: tenant scoping happens exactly once, at the database connection level, and every subsequent query — no matter how it was written — inherits that scope automatically.


Step 1: Designing the JWT Payload

Your JWT is the root of trust for the entire pipeline, so its structure matters. A minimal, secure payload looks like this:

{
  "sub": "user_8f3a2b1c",
  "tenant_id": "org_4c9d7e21",
  "role": "member",
  "iat": 1757659200,
  "exp": 1757662800,
  "iss": "https://auth.yoursaas.com"
}

A few non-negotiable rules:

  • tenant_id must be set server-side at token issuance time, based on your identity provider's org/organization mapping — never derived from user input.
  • Never allow a tenant_id to be passed as a query parameter or request body field that overrides the JWT's claim. This is the #1 way multi-tenant apps get pwned.
  • Use short expiry times (15–60 minutes) and refresh tokens, so a leaked access token has a limited blast radius.
  • Sign with an asymmetric algorithm (RS256 or EdDSA) if multiple services need to verify tokens without sharing a secret.

Extracting the Tenant ID in Next.js Middleware

In the Next.js App Router, middleware runs on the edge before your route handlers, making it the ideal place to verify the JWT and attach tenant context.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";

const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;

export async function middleware(req: NextRequest) {
  const token = req.cookies.get("access_token")?.value;

  if (!token) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  try {
    const { payload } = await jwtVerify(
      token,
      await importPublicKey(JWT_PUBLIC_KEY),
      { algorithms: ["RS256"] }
    );

    const tenantId = payload.tenant_id as string;
    const userId = payload.sub as string;

    if (!tenantId) {
      return new NextResponse("Missing tenant context", { status: 403 });
    }

    // Attach verified context to downstream request headers.
    // These headers are set by OUR server, never trusted from the client.
    const requestHeaders = new Headers(req.headers);
    requestHeaders.set("x-verified-tenant-id", tenantId);
    requestHeaders.set("x-verified-user-id", userId);

    return NextResponse.next({ request: { headers: requestHeaders } });
  } catch (err) {
    return new NextResponse("Invalid or expired token", { status: 401 });
  }
}

export const config = {
  matcher: ["/api/chat/:path*", "/api/documents/:path*"],
};

The critical detail here: x-verified-tenant-id is set after cryptographic verification, inside middleware that the client cannot influence. Never read a tenant_id directly from a header the client sent — always derive it from a verified token.


Step 2: Securely Passing Context to the Express Backend

If your Next.js API routes call an internal Node.js/Express service (common in larger architectures where retrieval logic lives in a dedicated backend), don't just forward the original JWT. Instead, mint a short-lived internal service token signed with a separate key. This limits the damage if your Express service is ever compromised, and lets you rotate keys independently.

// lib/internal-token.ts
import { SignJWT } from "jose";

const INTERNAL_SIGNING_KEY = process.env.INTERNAL_SIGNING_KEY!;

export async function mintInternalToken(tenantId: string, userId: string) {
  const key = await importPrivateKey(INTERNAL_SIGNING_KEY);

  return await new SignJWT({ tenant_id: tenantId, user_id: userId })
    .setProtectedHeader({ alg: "EdDSA" })
    .setIssuedAt()
    .setExpirationTime("60s") // very short-lived, single request use
    .setIssuer("nextjs-gateway")
    .sign(key);
}
// app/api/chat/route.ts
export async function POST(req: Request) {
  const tenantId = req.headers.get("x-verified-tenant-id")!;
  const userId = req.headers.get("x-verified-user-id")!;
  const { query } = await req.json();

  const internalToken = await mintInternalToken(tenantId, userId);

  const response = await fetch(`${process.env.RAG_SERVICE_URL}/retrieve`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${internalToken}`,
    },
    body: JSON.stringify({ query }),
  });

  return Response.json(await response.json());
}

On the Express side, verify this token and — this is the important part — use it to configure the database session, not just to filter results in JavaScript.

// middleware/tenantContext.js
const { jwtVerify } = require("jose");

async function tenantContext(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing internal token" });
  }

  try {
    const token = authHeader.split(" ")[1];
    const { payload } = await jwtVerify(token, INTERNAL_PUBLIC_KEY, {
      algorithms: ["EdDSA"],
      issuer: "nextjs-gateway",
    });

    req.tenantId = payload.tenant_id;
    req.userId = payload.user_id;
    next();
  } catch (err) {
    return res.status(401).json({ error: "Invalid internal token" });
  }
}

module.exports = { tenantContext };

Step 3: Enforcing Isolation with PostgreSQL Row-Level Security

This is the heart of the whole system. Instead of trusting application code to filter by tenant_id, we push that responsibility into PostgreSQL itself.

Schema Setup with pgvector

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL
);

CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  title TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE chunks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  document_id UUID NOT NULL REFERENCES documents(id),
  content TEXT NOT NULL,
  embedding VECTOR(1536) NOT NULL
);

CREATE TABLE graph_nodes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  label TEXT NOT NULL,
  properties JSONB
);

CREATE TABLE graph_edges (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  source_id UUID NOT NULL REFERENCES graph_nodes(id),
  target_id UUID NOT NULL REFERENCES graph_nodes(id),
  relation TEXT NOT NULL
);

CREATE INDEX ON chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

Enabling Row-Level Security

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE graph_nodes ENABLE ROW LEVEL SECURITY;
ALTER TABLE graph_edges ENABLE ROW LEVEL SECURITY;

-- Also force RLS even for the table owner role, which is critical
-- because your app's DB user often owns these tables.
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
ALTER TABLE chunks FORCE ROW LEVEL SECURITY;
ALTER TABLE graph_nodes FORCE ROW LEVEL SECURITY;
ALTER TABLE graph_edges FORCE ROW LEVEL SECURITY;

Writing the Isolation Policies

CREATE POLICY tenant_isolation_documents ON documents
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY tenant_isolation_chunks ON chunks
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY tenant_isolation_graph_nodes ON graph_nodes
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY tenant_isolation_graph_edges ON graph_edges
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

The current_setting('app.current_tenant_id') function reads a session-scoped configuration parameter. If it's never set, PostgreSQL throws an error rather than silently returning all rows — which is exactly the fail-safe behavior you want. To make this explicit and avoid unhandled exceptions, use the two-argument form with a sensible default:

CREATE POLICY tenant_isolation_chunks ON chunks
  USING (
    tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID
  );

With true as the second argument, current_setting returns NULL instead of throwing if unset — and since tenant_id = NULL never evaluates to true, the policy defaults to denying all rows, which is the correct fail-closed behavior.

Setting the Session Variable per Request

In your Express handler, every database transaction must begin by setting this variable using SET LOCAL, scoped to the transaction so it can never leak between requests sharing a pooled connection:

// db/withTenantScope.js
const { Pool } = require("pg");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function withTenantScope(tenantId, callback) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // SET LOCAL scopes the variable to this transaction only —
    // it's automatically reset when the transaction ends, even
    // if the underlying connection is returned to the pool.
    await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [
      tenantId,
    ]);

    const result = await callback(client);

    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

module.exports = { withTenantScope };

⚠️ Critical detail: Always use SET LOCAL (or set_config(..., true)) rather than plain SET. With connection pooling, a plain SET persists for the lifetime of the pooled connection — meaning the next request to reuse that connection could inherit the wrong tenant's context. SET LOCAL guarantees the setting dies with the transaction.


Step 4: GraphRAG Retrieval with Automatic Tenant Scoping

Now for the fun part — combining vector similarity search with graph traversal, both automatically isolated by RLS.

// services/graphRagRetrieval.js
const { withTenantScope } = require("../db/withTenantScope");
const { embedQuery } = require("./embeddings");

async function retrieveContext(tenantId, userQuery) {
  const queryEmbedding = await embedQuery(userQuery);

  return withTenantScope(tenantId, async (client) => {
    // Step 1: Vector similarity search — RLS silently restricts
    // this to the current tenant's chunks only.
    const { rows: topChunks } = await client.query(
      `SELECT id, document_id, content,
              1 - (embedding <=> $1) AS similarity
       FROM chunks
       ORDER BY embedding <=> $1
       LIMIT 8`,
      [queryEmbedding]
    );

    // Step 2: Graph expansion — pull related entities/nodes
    // connected to the documents behind the top chunks.
    const documentIds = topChunks.map((c) => c.document_id);

    const { rows: relatedNodes } = await client.query(
      `SELECT gn.label, gn.properties, ge.relation
       FROM graph_edges ge
       JOIN graph_nodes gn ON gn.id = ge.target_id
       JOIN documents d ON d.id = ANY($1::uuid[])
       WHERE ge.source_id IN (
         SELECT id FROM graph_nodes WHERE properties->>'document_id' = ANY($2)
       )
       LIMIT 20`,
      [documentIds, documentIds]
    );

    return { chunks: topChunks, graphContext: relatedNodes };
  });
}

module.exports = { retrieveContext };

Notice what's absent from this query: there is no WHERE tenant_id = $tenantId anywhere. That's the point. Even if this query were generated dynamically by an LLM-based query planner, or written carelessly by a new hire, RLS silently and automatically restricts every SELECT, UPDATE, and DELETE to the current tenant's rows. The database enforces the contract — your application code doesn't have to.

Assembling the Final Prompt

async function buildRagPrompt(tenantId, userQuery) {
  const { chunks, graphContext } = await retrieveContext(tenantId, userQuery);

  const contextBlock = chunks.map((c) => `- ${c.content}`).join("\n");
  const relationsBlock = graphContext
    .map((r) => `${r.relation} -> ${r.label}`)
    .join("\n");

  return `
You are answering strictly using the tenant's own knowledge base.

Relevant document excerpts:
${contextBlock}

Related entities and relationships:
${relationsBlock}

User question: ${userQuery}
`;
}

Testing Your Isolation Guarantees

Never ship multi-tenant RLS without adversarial testing. Here's a minimal test pattern using a real PostgreSQL connection (not mocks):

// tests/rls-isolation.test.js
const { withTenantScope } = require("../db/withTenantScope");

test("Tenant B cannot read Tenant A's chunks even via raw SQL", async () => {
  const tenantAId = "11111111-1111-1111-1111-111111111111";
  const tenantBId = "22222222-2222-2222-2222-222222222222";

  const resultAsTenantB = await withTenantScope(tenantBId, async (client) => {
    return client.query("SELECT * FROM chunks WHERE tenant_id = $1", [
      tenantAId,
    ]);
  });

  // Even though we explicitly asked for Tenant A's data,
  // RLS should return zero rows.
  expect(resultAsTenantB.rows.length).toBe(0);
});

test("Unset tenant context returns no rows (fail-closed)", async () => {
  const pool = require("../db/pool");
  const client = await pool.connect();
  const result = await client.query("SELECT * FROM chunks");
  expect(result.rows.length).toBe(0);
  client.release();
});

If either of these tests fails, do not deploy. That first test is the one that would have caught the "forgotten WHERE clause" scenario from the introduction — because it doesn't matter what the query says, RLS overrides it.


🚀 Pro Tips

  • Use a dedicated, non-superuser Postgres role for your app. RLS is bypassed entirely for superusers and table owners unless you explicitly run FORCE ROW LEVEL SECURITY. Always apply FORCE and connect through a role with minimal privileges.
  • Index your tenant_id columns alongside your vector indexes. A composite approach (partitioning by tenant, or a B-tree index on tenant_id combined with the ivfflat/hnsw vector index) keeps query plans fast even with RLS predicates added transparently.
  • Log the active app.current_tenant_id on every query in staging. A simple pg_stat_activity audit or query logging middleware helps you catch any code path that never set the session variable.
  • Rotate your internal service-to-service signing keys separately from your user-facing JWT keys. This limits blast radius if one service is compromised.
  • Consider pgvector's HNSW index (available since pgvector 0.5+) over ivfflat for better recall/latency tradeoffs at scale — RLS predicates apply identically regardless of index type.
  • Add a canary tenant. Seed a permanent test tenant with dummy documents and run isolation checks against it in production on a schedule (via a cron job or synthetic monitoring) to catch regressions early.

Common Mistakes to Avoid

  • Trusting client-supplied tenant IDs. Never accept a tenant_id from a request body, query string, or unverified header. It must always be derived from a cryptographically verified JWT claim.
  • Using plain SET instead of SET LOCAL with connection pooling. This is the most common way RLS context leaks between unrelated requests sharing a pooled connection.
  • Forgetting FORCE ROW LEVEL SECURITY. Table owners bypass RLS policies by default — a detail that catches almost every team the first time they set this up.
  • Letting background jobs or admin scripts connect without setting tenant context. Any script that talks to these tables must go through the same withTenantScope pattern — no exceptions for "just this once."
  • Caching retrieval results without tenant-scoped cache keys. A Redis cache key like query:${hash} without a tenant_id prefix will absolutely serve one tenant's answer to another.
  • Assuming RLS is a performance-free abstraction. RLS policies are applied as additional query predicates — poorly indexed tenant_id columns can silently degrade performance at scale. Always EXPLAIN ANALYZE your retrieval queries under RLS.
  • Skipping isolation tests in CI. Isolation bugs are invisible in normal development and testing unless you specifically write adversarial, cross-tenant test cases.

📌 Key Takeaways

  • Application-level tenant_id filtering is fragile — a single missed WHERE clause, a careless LLM-generated query, or a background job can leak sensitive cross-tenant data.
  • PostgreSQL Row-Level Security shifts isolation enforcement to the database engine itself, making leaks structurally impossible rather than just "unlikely."
  • The tenant_id must be extracted from a cryptographically verified JWT in Next.js middleware, never trusted from client-supplied headers or request bodies.
  • Use short-lived, separately-signed internal tokens when propagating tenant context from Next.js to a Node.js/Express backend.
  • Always use SET LOCAL (or set_config with is_local = true) to scope PostgreSQL session variables to a single transaction — plain SET leaks across pooled connections.
  • FORCE ROW LEVEL SECURITY is required if your application's database role owns the tables — otherwise RLS policies are silently bypassed.
  • GraphRAG retrieval (vector search + graph traversal) inherits tenant isolation automatically once RLS is in place, with zero manual filtering logic in your retrieval code.
  • Adversarial, cross-tenant tests belong in your CI pipeline — isolation bugs don't show up in normal functional testing.

Conclusion

Multi-tenant RAG systems carry a unique risk profile: you're not just serving different users the same application — you're feeding entire documents into a language model's context window, and a single misrouted chunk can expose an entire company's confidential data to a competitor sharing your infrastructure.

The pattern in this guide — verified JWT claims, short-lived internal tokens, and PostgreSQL Row-Level Security enforced with FORCE and transaction-scoped session variables — moves tenant isolation out of your application logic and into the database engine, where it can't be forgotten, bypassed, or broken by a well-intentioned refactor. Combined with a GraphRAG retrieval layer that inherits this isolation for free, you get a pipeline where "chat with your data" actually means your data, every single time.

Security in AI SaaS isn't a feature you bolt on later — it's an architectural decision you make on day one. Building it into the database layer, rather than hoping every engineer remembers a WHERE clause, is what separates a resilient B2B AI product from a future headline.


References

Discussion

All Articles
RAGPostgreSQLNext.jsMulti-TenancyJWTSecuritypgvectorGraphRAG

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.