In a multi-tenant B2B platform, data leakage is an extinction-level event. If Property A logs into your dashboard and accidentally sees the guest data or revenue metrics for Property B, your platform's trust is permanently broken.
Most developers handle data isolation at the application layer. They rely on their Node.js middleware or ORM to append WHERE property_id = X to every single database query.
This is a massive security risk. All it takes is one junior developer forgetting a WHERE clause in a new endpoint, and you have exposed cross-tenant data.
To build a truly secure, enterprise-grade architecture, you must push security down to the database layer. Here is how to build a multi-tenant fortress using PostgreSQL Row-Level Security (RLS).
The Concept: Database-Enforced Isolation
Row-Level Security (RLS) is a PostgreSQL feature that acts as a bouncer at the table level.
When RLS is enabled, the database evaluates a specific policy before returning any rows. Even if a compromised API sends a malicious SELECT * FROM guests query, the database itself will intercept the request and only return the rows that the current user is explicitly allowed to see.
The application layer becomes blind to data it shouldn't access.
Step 1: The Schema and Anchor Keys
First, every table in your database that contains tenant-specific data needs an anchor key. In a B2B platform, this is usually the tenant_id or property_id.
-- Create our base properties table
CREATE TABLE properties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
-- Create a guests table anchored to a specific property
CREATE TABLE guests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
property_id UUID REFERENCES properties(id) NOT NULL,
full_name TEXT NOT NULL,
email TEXT NOT NULL
);
Next, we enable RLS on the guests table. Once you run this command, the table defaults to "deny all." If you query it right now, it will return zero rows, even for the database administrator.
ALTER TABLE guests ENABLE ROW LEVEL SECURITY;
Step 2: Passing the Execution Context
For the database to know which rows to return, our Node.js middleware needs to tell PostgreSQL exactly who is making the request before executing the query.
Instead of passing the property_id in a WHERE clause, we inject it into the database session configuration.
Here is how you set the context in a Node.js transaction:
// Example using the 'pg' module in Node.js
async function getGuestsForProperty(client, propertyId) {
try {
await client.query('BEGIN');
// 1. Inject the tenant context securely into the database session
await client.query(`SELECT set_config('app.current_property_id', $1, true)`, [propertyId]);
// 2. Execute a naive query (No WHERE clause needed!)
const result = await client.query('SELECT * FROM guests');
await client.query('COMMIT');
return result.rows;
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
Notice that our SELECT * FROM guests query has no filtering logic. The application doesn't need to know how to filter the data.
Step 3: Writing the RLS Policy
Now we write the rule inside PostgreSQL that connects the session context to the anchor key.
We create a policy that tells the database: "Only allow reads, inserts, and updates if the property_id on the row matches the app.current_property_id currently set in the session."
CREATE POLICY tenant_isolation_policy ON guests
FOR ALL -- Applies to SELECT, INSERT, UPDATE, DELETE
USING (
property_id = current_setting('app.current_property_id')::UUID
)
WITH CHECK (
property_id = current_setting('app.current_property_id')::UUID
);
- USING: Determines which existing rows are visible to the query.
-
WITH CHECK: Ensures that any new rows being
INSERTed orUPDATEd also adhere to the rule. (You cannot insert a guest for Property B if your session is scoped to Property A).
The Failsafe Advantage
Letβs look at why this is so powerful.
Imagine a developer writes a reporting script and makes a catastrophic error:
DELETE FROM guests;
In a standard SaaS architecture, this deletes the entire database.
In an RLS-backed architecture, this query does nothing. If the session context app.current_property_id isn't explicitly set, the query fails safely. If the session is set to Property A, it only deletes Property A's guests. Property B's data remains untouchable.
Conclusion
Application-layer security is fragile because it relies on developers never making a mistake in their SQL syntax or ORM logic.
By utilizing PostgreSQL Row-Level Security, you decouple authorization from your application code. You create a multi-tenant fortress where cross-contamination is mathematically impossible at the database layer. For enterprise SaaS handling sensitive client records, this is the only acceptable standard.
Top comments (0)