Securing Multi-Tenant APIs with PostgreSQL RLS and JWT Claims
The Fragility of Application-Layer Authorization
In the world of multi-tenant SaaS applications, data isolation is not just a feature; it's a foundational security requirement. The most common pattern to achieve this is diligent, manual, and ultimately fragile: application-layer filtering. Every database query is meticulously crafted with a WHERE tenant_id = ? clause, typically after a middleware function verifies a user's session and attaches their tenant_id to the request object.
While this approach works, it carries significant technical debt and risk:
WHERE clause in a complex JOIN or a hastily written reporting query can lead to a catastrophic data leak across tenants. This risk scales with team size and codebase complexity.For senior engineers, these are not hypothetical problems. They are the latent bugs and security vulnerabilities we're paid to anticipate and prevent. The core issue is that we're making the application the sole gatekeeper to the data. A more robust architecture enforces security at the layer closest to the data: the database itself.
The Architectural Shift: Data-Layer Enforcement with RLS and JWTs
This is where PostgreSQL's Row-Level Security (RLS) becomes a game-changer. RLS allows you to define policies directly on a table, which act as an automatic, implicit WHERE clause for any query executed against that table. The database, not the application, becomes the ultimate enforcer of data access rules.
Our advanced pattern combines this powerful database feature with a standard of modern authentication: JSON Web Tokens (JWTs). The JWT, issued upon successful authentication, will carry the necessary authorization context (like tenant_id and role) as claims. We will then securely propagate this context from the application to the database for every transaction.
The high-level data flow looks like this:
user_id, role, and tenant_id.Authorization header.SET LOCAL a.custom.claim.tenant_id = 'the-tenant-id-from-jwt';.SELECT * FROM projects;.projects table. This policy reads the session-local variable (a.custom.claim.tenant_id) and dynamically appends the filtering logic, effectively transforming the query into SELECT * FROM projects WHERE tenant_id = 'the-tenant-id-from-jwt';.This is a paradigm shift. The application code becomes cleaner and less error-prone, as it no longer needs to concern itself with tenant isolation. Security is centralized, auditable, and impossible to bypass, even with direct database access (for non-superusers).
Deep Dive: A Production-Grade Implementation
Let's build this system from the ground up. We'll use a Node.js/Express API with the pg library as our example, but the principles are transferable to any language or framework.
1. Database Schema and RLS Setup
First, let's define a simple multi-tenant schema.
-- Create tenants and users tables
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member' -- e.g., 'member', 'admin'
);
-- The resource table we want to protect
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id)
);
-- Crucial: Add indexes for performance
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
Now, let's enable RLS on the projects table and create our first policy.
-- Step 1: Enable RLS on the target table.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Step 2: Create a policy that enforces tenant isolation.
-- This policy applies to ALL commands (SELECT, INSERT, UPDATE, DELETE).
CREATE POLICY tenant_isolation_policy ON projects
FOR ALL
USING (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid);
Let's break down this policy definition:
* ALTER TABLE projects ENABLE ROW LEVEL SECURITY;: This command activates RLS for the table. By default, this also creates a deny all policy, meaning no rows will be returned until a permissive policy is added.
* CREATE POLICY tenant_isolation_policy ON projects: We're defining a new policy named tenant_isolation_policy on the projects table.
* FOR ALL: This policy applies to SELECT, INSERT, UPDATE, and DELETE commands.
* USING (...): This clause is for reads (SELECT). A row is visible only if this expression evaluates to true.
* WITH CHECK (...): This clause is for writes (INSERT, UPDATE). A write operation is allowed only if this expression evaluates to true for the new or updated row. This is a critical security feature that prevents a user from one tenant from inserting or moving data into another tenant's scope.
* current_setting('a.custom.claim.tenant_id', true)::uuid: This is the core of our dynamic system. The current_setting() function retrieves a runtime configuration parameter. We've namespaced our parameter with a.custom.claim. to avoid conflicts with standard PostgreSQL settings. The second argument, true, tells the function not to error if the setting is missing (it will return an empty string). We then cast the result to uuid.
2. JWT Generation with Custom Claims
When a user logs in, we need to generate a JWT that contains the necessary claims for our RLS policies.
// Node.js example using 'jsonwebtoken'
const jwt = require('jsonwebtoken');
// Assume 'user' is an object retrieved from the database after a successful login
// user = { id: 'user-uuid', tenant_id: 'tenant-uuid', role: 'admin' }
function generateUserToken(user) {
const payload = {
sub: user.id, // Standard 'subject' claim
// Custom claims namespaced for our application
'https://myapp.com/claims': {
tenant_id: user.tenant_id,
user_id: user.id,
role: user.role
}
};
const options = {
expiresIn: '8h',
issuer: 'https://api.myapp.com',
audience: 'https://myapp.com'
};
const secret = process.env.JWT_SECRET;
return jwt.sign(payload, secret, options);
}
3. The API Middleware: Propagating JWT Context to the Database
This is the most critical piece of application code. The middleware needs to verify the JWT and then set the session variables on the database connection before passing control to the route handler. This ensures that every query within that request's lifecycle is correctly scoped.
// Node.js/Express middleware using 'pg' pool
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
const pool = new Pool(); // Configure with your DB credentials
async function authAndSetRLS(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).send('Unauthorized: No token provided.');
}
let claims;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
claims = decoded['https://myapp.com/claims'];
if (!claims || !claims.tenant_id || !claims.user_id) {
throw new Error('Invalid claims');
}
} catch (err) {
return res.status(401).send('Unauthorized: Invalid token.');
}
// IMPORTANT: We must use a single client for the entire transaction.
// We attach it to the request object to be used by subsequent handlers.
const client = await pool.connect();
req.dbClient = client;
try {
// Use SET LOCAL to scope the settings to the current transaction only.
// This is VITAL for preventing state leakage in a connection pool.
await client.query('BEGIN'); // Start a transaction
// Using parameterized queries to prevent SQL injection
await client.query(`SET LOCAL a.custom.claim.tenant_id = $1`, [claims.tenant_id]);
await client.query(`SET LOCAL a.custom.claim.user_id = $1`, [claims.user_id]);
await client.query(`SET LOCAL a.custom.claim.role = $1`, [claims.role]);
// The claims are now set for the duration of this transaction.
// We can also attach them to the request for application-level logic if needed.
req.userClaims = claims;
// Listen for the response to finish to commit/rollback and release the client
res.on('finish', async () => {
try {
// The 'error' event is emitted if the headers were sent but the request failed after.
if (!res.headersSent || res.statusCode >= 400) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} finally {
client.release();
}
});
next();
} catch (err) {
// If setting RLS fails, rollback and release the client immediately
await client.query('ROLLBACK');
client.release();
res.status(500).send('Failed to set security context.');
}
}
With this middleware in place, our route handlers become beautifully simple:
app.get('/projects', authAndSetRLS, async (req, res) => {
try {
// The dbClient is the same connection where RLS settings were applied.
const result = await req.dbClient.query('SELECT id, name FROM projects');
res.json(result.rows);
} catch (err) {
// The 'finish' event handler will still fire to rollback and release the client.
console.error(err);
res.status(500).send('Error fetching projects.');
}
});
The SELECT query has no WHERE tenant_id clause. It doesn't need one. PostgreSQL handles the security transparently.
Advanced Scenarios and Edge Cases
Real-world systems are more complex than simple tenant isolation. Here’s how to extend this pattern.
1. Role-Based Access Control (RBAC)
What if a tenant admin can see all projects in their tenant, but a member can only see projects they created? We can create multiple policies. PostgreSQL combines permissive policies with a logical OR.
First, we drop the old policy and create two new, more specific ones.
DROP POLICY tenant_isolation_policy ON projects;
-- Policy for admins: can see any project within their tenant.
CREATE POLICY admin_access_policy ON projects
FOR SELECT
USING (
current_setting('a.custom.claim.role', true) = 'admin' AND
tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid
);
-- Policy for members: can only see projects they created within their tenant.
CREATE POLICY member_access_policy ON projects
FOR SELECT
USING (
current_setting('a.custom.claim.role', true) = 'member' AND
created_by = current_setting('a.custom.claim.user_id', true)::uuid AND
tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid
);
-- Don't forget the write policy! This one can remain simple.
CREATE POLICY write_policy ON projects
FOR INSERT, UPDATE, DELETE
USING (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid);
Now, when a request comes in, PostgreSQL will evaluate both admin_access_policy and member_access_policy. If the user's JWT has role: 'admin', the first policy will match. If it has role: 'member', the second will match. The security logic is co-located with the data and remains invisible to the application.
2. Handling Superusers and Internal Tools
By default, RLS policies do not apply to table owners or superusers. This is often desirable for database maintenance. However, for internal admin panels or support tools, you might want to enforce RLS even for privileged roles. You can do this with FORCE.
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
This command ensures that RLS policies are applied to all roles, including the table owner. To grant bypass access, you would typically create a specific role for that purpose and grant it the BYPASS RLS attribute.
CREATE ROLE internal_tools_role WITH BYPASS RLS;
-- Grant this role to the database user for your internal tool.
3. Cross-Tenant Data Access for Privileged Users
Imagine a scenario where a global support user needs to access data for a specific set of tenants they manage. We can't use a single tenant_id claim. Instead, we can pass an array of tenant IDs in the JWT.
JWT Payload:
{
"https://myapp.com/claims": {
"user_id": "support-user-uuid",
"role": "support_agent",
"allowed_tenants": ["tenant-uuid-1", "tenant-uuid-2"]
}
}
Middleware: The middleware would serialize this array into a string for the session variable.
// In authAndSetRLS middleware
const allowedTenants = claims.allowed_tenants || [];
await client.query(`SET LOCAL a.custom.claim.allowed_tenants = $1`, [allowedTenants.join(',')]);
RLS Policy: The policy uses PostgreSQL's array functions to check for membership.
CREATE POLICY support_agent_access_policy ON projects
FOR SELECT
USING (
current_setting('a.custom.claim.role', true) = 'support_agent' AND
tenant_id = ANY(string_to_array(current_setting('a.custom.claim.allowed_tenants', true), ',')::uuid[])
);
This powerful pattern allows for highly granular, dynamic access control defined entirely by the JWT's contents, without changing application code.
Performance Considerations and Benchmarking
Moving logic into the database always raises performance questions. Fortunately, PostgreSQL's implementation of RLS is highly optimized.
current_setting() Overhead: The current_setting() function is extremely fast. It performs an in-memory lookup of the session's configuration parameters. Its overhead is negligible compared to the cost of the query itself.EXPLAIN ANALYZE.Let's assume we've set a.custom.claim.tenant_id to a specific UUID in our session.
EXPLAIN ANALYZE SELECT * FROM projects WHERE name LIKE 'Feature%';
The Output Will Look Something Like This:
Bitmap Heap Scan on projects (cost=...)
Recheck Cond: (name ~~ 'Feature%'::text)
Filter: (tenant_id = '...'::uuid) -- <-- RLS POLICY APPLIED HERE!
-> Bitmap Index Scan on idx_projects_name (cost=...)
Index Cond: (name ~~ 'Feature%'::text)
Crucially, the Filter: (tenant_id = '...'::uuid) is part of the execution plan. PostgreSQL is smart enough to combine the RLS policy with the explicit query predicates before execution. This means it can use indexes on tenant_id just as effectively as if you had written the WHERE clause yourself. Your existing indexing strategies on foreign keys like tenant_id will work perfectly with RLS.
SET LOCAL vs. SET): This is a critical production detail. When using a connection pooler like PgBouncer, connections are reused across different users and requests. * Using SET would modify the setting for the entire session, potentially leaking one user's tenant_id to another user's request that happens to receive the same pooled connection. This is a severe security flaw.
Using SET LOCAL scopes the setting to the current transaction only*. Once the transaction is committed or rolled back, the setting reverts to its previous value. This makes it safe to use with transaction-level connection pooling, as each request is wrapped in its own transaction, gets its own local settings, and leaves the connection clean for the next request.
Trade-offs and When Not to Use This Pattern
This architecture is powerful, but it's not a silver bullet. Consider the following trade-offs:
* Increased Database Complexity: Your authorization logic now lives in SQL. This can be less familiar to application developers, and debugging can involve inspecting database policies rather than application code. It requires your team to be comfortable with advanced database features.
* Vendor Lock-in: This is a PostgreSQL-specific solution. If you ever plan to migrate to MySQL or another database, you will need to completely re-implement this authorization logic in your application layer.
* Testing: Unit and integration testing strategies need to adapt. Your tests must now be able to set the appropriate database session variables to simulate requests from different users and tenants. This often involves creating dedicated test helper functions that wrap test queries within a transaction that sets the RLS context.
* Business Logic vs. Data Access: RLS is best suited for coarse-grained, data-access-level authorization (e.g., "Can this user see this row?"). It is not a replacement for fine-grained business logic authorization (e.g., "Can a user on the 'free' plan create a sixth project?"). That logic still belongs in the application layer.
Conclusion: A New Foundation for Secure Systems
By moving tenant isolation and role-based data access from the application layer to the database layer, we fundamentally harden our system's security posture. This pattern creates a single, auditable source of truth for authorization that is enforced for every query, regardless of its origin.
The application code is simplified, reducing the surface area for human error and making developers more productive. While it requires a deeper understanding of PostgreSQL's capabilities and introduces new testing considerations, the payoff is a zero-trust data access model that is more robust, scalable, and secure.
For senior engineers building the next generation of multi-tenant applications, leveraging PostgreSQL RLS with JWT claims isn't just an interesting technique—it's a superior architectural pattern for achieving data security by design.