Marcio Cunha

Why use Row-Level Security in PostgreSQL to isolate tenants

Learn how to isolate customer data in a shared database using PostgreSQL Row-Level Security, ensuring robust multi-tenant data protection through session context.

Marcio Cunha•6 min
Also available in:PortuguêsEspañol
Summary
  • Row-level data isolation prevents accidental data leaks between different tenants in the same relational table
  • PostgreSQL session variables allow injecting the current tenant identifier dynamically and securely
  • Security policies reduce application code complexity by delegating filtering logic directly to the database
  • Persistent connection pools require careful context cleanup to prevent data contamination across requests
  • Testing policies with restricted database users guarantees data protection even when application bugs occur

The challenge of data isolation without multiplying databases

When building systems that serve multiple companies or clients within the same shared environment, a critical challenge arises: how to ensure that Company A never sees Company B's data. The traditional approach involves provisioning a separate database for each client or manually appending filters to every single SQL query. However, managing hundreds of databases drives up operational costs and complicates backup routines. On the other hand, relying solely on application code to filter queries opens dangerous doors to human error and accidental leaks. This is precisely where understanding multi-tenant isolation strategies in relational databases becomes essential for keeping your architecture clean and secure.

To solve this dilemma at its root, PostgreSQL offers a powerful feature called Row-Level Security. Simply put, it is a mechanism where the database itself decides which rows can be seen or modified by whoever is executing the query. When a user sends a command, the database transparently applies filters behind the scenes based on predefined rules. In practice, this means that even if a developer forgets to include a filter in a query, the database automatically blocks unauthorized access. This layered protection turns your database into the ultimate guardian of sensitive information.

How PostgreSQL knows who is requesting the data

To let the database know which tenant is making a request, we need to inject context into the active session. PostgreSQL allows creating custom variables that persist throughout the lifetime of an open connection. When your application receives a web request, it first authenticates the user and determines their tenant association. Then, before running any business queries, the application executes a fast command to store this identifier inside the database session using configurable parameters.

This process acts like a digital badge that the application hands to the database as soon as the connection is established for that specific request. For deeper insights on managing these connections efficiently, it is worth checking out the guide on why Prisma fails on PostgreSQL and how to use PgBouncer, which addresses the necessary precautions around connection pooling. Without this updated badge, the database would not know who the user is, and security rules would fail to filter records correctly. Combining session variables with table-level rules brings true multi-tenant security to life.

Creating the table and enabling security guards

Imagine a practical scenario where we have a corporate orders table storing information from multiple distinct clients. The first step is creating this table normally, making sure there is a dedicated column to identify the owner of each record. Next, we must explicitly enable the security mechanism on this specific table, instructing the database to start enforcing the rules we will define shortly. Here is how to structure this initial foundation using plain SQL commands.

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    tenant_id UUID NOT NULL,
    description TEXT NOT NULL,
    amount NUMERIC(10, 2) NOT NULL
);

-- Enables row-level security on the table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

Once security is enabled on the table, PostgreSQL's default behavior changes drastically for any user who is not a superuser or the table owner. If no policy is created after enabling the feature, the database simply hides all rows, returning empty results by default. This happens for extreme security reasons: the database prefers blocking everything over leaving any data exposed by oversight. Therefore, the mandatory next step is teaching the database exactly who can see what through access policies.

Writing context-aware access policies

Security policies are the logical instructions that tell the database when to allow or bar a row. We create a rule using the CREATE POLICY command, defining whether it applies to reading, inserting, or modifying data. Inside this rule, we use a system function that reads the session variable we set earlier. This way, the database compares the identifier stored in the table row with the identifier stored in the user's active session.

Below is a practical example of how to write this rule directly in your relational database console. Notice that we use a function to safely convert the text session variable into the correct universal unique identifier format for precise comparisons.

CREATE POLICY tenant_isolation_policy ON orders
    AS RESTRICTIVE
    FOR ALL
    USING (tenant_id = nullif(current_setting('app.current_tenant_id', true), '')::uuid)
    WITH CHECK (tenant_id = nullif(current_setting('app.current_tenant_id', true), '')::uuid);

This rule is split into two fundamental parts to ensure comprehensive protection. The USING clause controls which existing rows can be seen or modified during read, update, and delete queries. Meanwhile, the WITH CHECK clause ensures that any newly inserted or modified records strictly belong to the active tenant in the session, preventing malicious users from injecting forged data linked to another company. This symmetry eliminates common logical loopholes in distributed systems.

Operational considerations and pitfalls when using shared connections

Implementing session-context security requires strict discipline in your application code. When using connection pooling tools to optimize performance, database connections are recycled and reused by different users at different times. If your application forgets to clear or reset the session variable when returning the connection to the pool, the next user grabbing that same connection will inherit the previous context, potentially viewing confidential data by mistake. To prevent this disaster, always ensure context definition happens inside an isolated transaction right after acquiring the connection.

Another crucial performance aspect involves indexing large tables. Because the database must evaluate the comparison filter on every scanned row, creating efficient indexes on the tenant identifier column is strictly mandatory. Without an adequate index on this column, the database will be forced to perform sequential full table scans on every request, severely degrading system performance as the dataset grows. Careful planning of infrastructure and indexing ensures that security does not come at the expense of application speed. To explore these architectural concepts further in production, see our related technical analysis on Slow Linux disk? How to spot IOPS bottlenecks and latency.

Final considerations

Using Row-Level Security in PostgreSQL represents a mature evolution for multi-tenant architectures, combining maintenance simplicity with reinforced security directly at the storage core. By delegating data isolation to the database, we remove a heavy layer of manual validation from the application code and build an insurmountable barrier against accidental leaks. Although it requires rigor in session management and connection pooling, the gains in robustness and operational peace of mind easily outweigh the initial setup effort.

Adopting this strategy early in your project prevents complex refactoring in the future, allowing your application to scale healthily and securely. Always remember to thoroughly test policies with restricted database users and maintain active monitoring of query performance to ensure your database responds swiftly under any volume of traffic.