PostgreSQL RLS with JWTs for Microservice Tenancy Control
The Tenancy Conundrum in Microservices
In a monolithic architecture, managing user-specific data access is relatively straightforward. The application has a coherent session object, accessible throughout the codebase, containing the current user's ID, organization, and roles. Every database query can be systematically augmented with a WHERE organization_id = ? clause derived from this session context.
Microservices shatter this paradigm. An orders-service may receive a gRPC call from a frontend-api service, which in turn received a request from an API gateway. The orders-service is several hops away from the initial authentication event. It trusts the internal network but has no inherent knowledge of the end-user. The default, and dangerously naive, approach is to pass identity information (user_id, organization_id) as parameters in every internal API call and subsequently in every function call down to the data access layer.
This approach is fundamentally flawed for several reasons:
WHERE clause to a new query introduces a critical data leak across tenants.We need a more robust solution that enforces tenancy at the lowest possible layer: the database itself. This is where PostgreSQL's Row-Level Security (RLS) becomes a powerful tool, but only if we can bridge the context gap between our stateless microservice and the stateful database session.
This article presents a pattern for securely propagating JWT claims into the PostgreSQL session, allowing RLS policies to make dynamic, context-aware decisions.
The Architecture: Propagating JWT Claims to the Database Session
The core principle is to make the database aware of the identity of the user making a request. We achieve this by using the database's own session configuration as a temporary, request-scoped storage for JWT claims.
Here's the flow:
auth-service, which issues a signed JWT containing claims like sub (user ID), org_id, and role.products-service) in an Authorization header.products-service has a piece of middleware that runs at the beginning of every request. This middleware:a. Validates the JWT signature and expiration again (zero-trust).
b. Extracts the necessary claims.
c. Obtains a database connection from its pool.
d. Crucially, within a single database transaction, it sets session-local variables using SET LOCAL. For example: SET LOCAL "request.jwt.claims.org_id" = 'org_123';
SELECT * FROM products;) without any explicit WHERE organization_id = ? clauses.products table. These policies read the session variables (e.g., using current_setting('request.jwt.claims.org_id')) to filter the rows, effectively adding the WHERE clause at the database level.SET LOCAL are automatically discarded, ensuring the connection is clean when returned to the pool.This pattern centralizes tenancy logic within the database schema, making it unbypassable and consistent across all services that interact with a given table.
Step 1: Database Schema and RLS Foundation
First, let's define a simple multi-tenant schema. We'll have organizations and products, where each product belongs to one organization.
-- Create a sample organizations table
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
-- Create a products table with a foreign key to organizations
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL
);
-- Create an index on the tenancy column, as it will be used in every RLS check
CREATE INDEX idx_products_organization_id ON products(organization_id);
-- Insert some sample data for two different tenants
INSERT INTO organizations (id, name) VALUES
('a4e3c2b1-3b6a-4b0d-8215-25d2f3a8b4f2', 'Org A'),
('b5f4d3c2-4c7b-5c1e-9326-36e3g4b9c5g3', 'Org B');
INSERT INTO products (organization_id, name, price) VALUES
('a4e3c2b1-3b6a-4b0d-8215-25d2f3a8b4f2', 'Widget A', 99.99),
('b5f4d3c2-4c7b-5c1e-9326-36e3g4b9c5g3', 'Gadget B', 149.50);
Now, we enable RLS on the products table. This is a critical step. Once enabled, the default behavior is to deny all access unless a specific policy grants it.
-- Enable Row Level Security on the products table
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- It's a best practice to also force RLS for the table owner.
-- This prevents accidental bypass during migrations or administrative tasks.
ALTER TABLE products FORCE ROW LEVEL SECURITY;
If you try to SELECT * FROM products; now (as a non-superuser), you will get zero rows back. RLS is working, blocking everything by default. We need to create policies to grant access.
Step 2: Crafting Dynamic RLS Policies with Session Variables
RLS policies are expressions that return a boolean. If the expression is true for a given row, that row is visible or can be operated upon. We'll use the current_setting() function to read the session variables we'll set later from our application.
-- A helper function to get a claim, returning NULL if not found.
-- The second parameter to current_setting('...', true) makes it not throw an error if the setting is missing.
CREATE OR REPLACE FUNCTION get_jwt_claim(claim_name TEXT) RETURNS TEXT AS $$
BEGIN
RETURN current_setting('request.jwt.claims.' || claim_name, true);
EXCEPTION
WHEN UNDEFINED_OBJECT THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
-- Policy for SELECT operations
-- This policy allows a user to see products if their org_id claim matches the product's organization_id
CREATE POLICY select_products_own_org
ON products
FOR SELECT
USING (organization_id::text = get_jwt_claim('org_id'));
-- Policy for INSERT operations
-- The WITH CHECK clause is used for INSERT and UPDATE.
-- It ensures that any new row being created (or an existing row being moved)
-- adheres to the policy.
CREATE POLICY insert_products_own_org
ON products
FOR INSERT
WITH CHECK (organization_id::text = get_jwt_claim('org_id'));
-- Policy for UPDATE operations
-- 'USING' applies to which rows can be seen and therefore updated.
-- 'WITH CHECK' ensures you can't update a row to move it to an organization you don't belong to.
CREATE POLICY update_products_own_org
ON products
FOR UPDATE
USING (organization_id::text = get_jwt_claim('org_id'))
WITH CHECK (organization_id::text = get_jwt_claim('org_id'));
-- Policy for DELETE operations
-- Only 'USING' is needed here, as you can only delete rows you can see.
CREATE POLICY delete_products_own_org
ON products
FOR DELETE
USING (organization_id::text = get_jwt_claim('org_id'));
A key distinction here is USING vs. WITH CHECK.
* USING (expression): Applies to commands that read data (SELECT) and determines which existing rows are visible to the command (UPDATE, DELETE).
WITH CHECK (expression): Applies to commands that write data (INSERT, UPDATE). It ensures that the new* data being written satisfies the expression.
For UPDATE, you often need both. USING ensures you can only target rows in your own tenant, and WITH CHECK prevents you from changing the organization_id to a tenant you don't have access to.
Step 3: The Microservice Middleware (Node.js & `node-postgres`)
Now for the application-side implementation. This middleware is the bridge that injects the JWT context into the database session. We'll use Node.js with Express and the pg (node-postgres) library.
// file: jwt-rls-middleware.js
const jwt = require('jsonwebtoken');
const { Pool } = require('pg');
const JWT_SECRET = 'your-super-secret-key'; // Use environment variables in production!
// Assume you have a configured pg Pool instance
const pool = new Pool({
connectionString: 'postgresql://user:password@host:port/database'
});
// A mock function to generate tokens for testing
function generateTestToken(userId, orgId, role) {
const payload = {
sub: userId,
org_id: orgId,
role: role,
};
return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
}
// The core middleware
async function rlsMiddleware(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).send('Unauthorized: No token provided.');
}
const token = authHeader.substring(7);
let decodedToken;
try {
decodedToken = jwt.verify(token, JWT_SECRET);
} catch (err) {
return res.status(401).send('Unauthorized: Invalid token.');
}
// Get a client from the pool. This client will be used for the entire request.
const client = await pool.connect();
req.dbClient = client; // Attach client to the request object
try {
// CRITICAL: Start a transaction.
await client.query('BEGIN');
// Securely set the session variables using parameterized queries to prevent SQL injection.
// We use SET LOCAL so the setting only lasts for the current transaction.
await client.query(`SET LOCAL "request.jwt.claims.sub" = $1`, [decodedToken.sub]);
await client.query(`SET LOCAL "request.jwt.claims.org_id" = $1`, [decodedToken.org_id]);
await client.query(`SET LOCAL "request.jwt.claims.role" = $1`, [decodedToken.role]);
// Proceed to the actual route handler
next();
} catch (err) {
// If setting claims fails, rollback and release the client
await client.query('ROLLBACK');
client.release();
console.error('Failed to set RLS context:', err);
res.status(500).send('Internal Server Error');
}
}
// Middleware to commit/rollback and release the client after the request is handled
async function transactionFinalizer(req, res, next) {
if (!req.dbClient) return next();
// The response has been sent, so we can clean up.
// We listen for the 'finish' event on the response object.
res.on('finish', async () => {
try {
// Check if the transaction is still active before trying to commit/rollback
// This handles cases where the route handler might have already committed.
const { rows } = await req.dbClient.query("SELECT txid_current_if_assigned() IS NOT NULL AS in_transaction");
if (rows[0].in_transaction) {
// If headers have been sent and status is 2xx, commit.
// Otherwise, rollback.
if (res.headersSent && res.statusCode >= 200 && res.statusCode < 300) {
await req.dbClient.query('COMMIT');
} else {
await req.dbClient.query('ROLLBACK');
}
}
} catch (err) {
console.error('Error during transaction finalization:', err);
// Even if finalization fails, we MUST release the client
} finally {
req.dbClient.release();
}
});
next();
}
module.exports = { rlsMiddleware, transactionFinalizer, generateTestToken };
Why SET LOCAL and Transactions are Non-Negotiable:
When using a connection pool, the same database connection will be reused by different requests from different users. If you were to use SET instead of SET LOCAL, the setting would persist on that connection after the request is finished. The next user to get that connection from the pool would inherit the previous user's identity, a massive security vulnerability.
By wrapping the entire request in a transaction and using SET LOCAL, we guarantee that our session variables are automatically discarded at COMMIT or ROLLBACK. This makes the pattern safe for use with connection pools.
Here's how you'd use it in an Express app:
// file: server.js
const express = require('express');
const { rlsMiddleware, transactionFinalizer, generateTestToken } = require('./jwt-rls-middleware');
const app = express();
// Apply middleware to all protected routes
app.use('/api', rlsMiddleware, transactionFinalizer);
app.get('/api/products', async (req, res) => {
try {
// The business logic is now beautifully simple.
// No need to pass org_id or user_id around.
const { rows } = await req.dbClient.query('SELECT * FROM products');
res.status(200).json(rows);
} catch (err) {
// The finalizer middleware will handle rollback
console.error(err);
res.status(500).send('Error fetching products');
}
});
// Example of how to generate tokens for testing with cURL
app.get('/token', (req, res) => {
const tokenA = generateTestToken('user-1', 'a4e3c2b1-3b6a-4b0d-8215-25d2f3a8b4f2', 'member');
const tokenB = generateTestToken('user-2', 'b5f4d3c2-4c7b-5c1e-9326-36e3g4b9c5g3', 'member');
res.json({ for_org_a: tokenA, for_org_b: tokenB });
});
app.listen(3000, () => console.log('Server running on port 3000'));
Now, if you run this server and make a request with a token for Org A, you will only see "Widget A". With a token for Org B, you will only see "Gadget B". The application code in the route handler is completely ignorant of tenancy, as it should be.
Advanced Scenarios and Edge Cases
Role-Based Access Control (RBAC)
What if an organization has admins who can see all products and members who can only see their own? We can add a role claim to the JWT and create a more complex policy structure. PostgreSQL policies of the same type (PERMISSIVE, the default) are combined with OR.
-- Add a policy to allow users with the 'admin' role to see all products in their org
-- For a support tool, this could be a global admin role that bypasses the org check entirely
CREATE POLICY select_products_admin_all_orgs
ON products
FOR SELECT
USING (get_jwt_claim('role') = 'super_admin');
Now, a user's access is determined by (is_super_admin) OR (org_id_matches). If a user with the super_admin role makes a request, the first policy will evaluate to true, and they will see all products. For a regular member, the first policy is false, but the second (select_products_own_org) will be true for their organization's products.
Handling Background Workers and System Tasks
A background worker processing a queue or a cron job running nightly reports has no user-initiated JWT. How does it operate on the data?
Option 1 (The Dangerous Way): Create a dedicated PostgreSQL user for the worker and grant it the BYPASSRLS attribute. This is simple but effectively punches a hole in your security model. A compromise of the worker's credentials gives full access.
Option 2 (The Secure Way): Treat the system as its own identity.
- Create a service-to-service authentication mechanism (e.g., OAuth Client Credentials flow) where the worker can obtain a JWT.
role: 'system_worker'.- Create RLS policies that recognize this role.
-- Policy allowing a system worker to access any product
CREATE POLICY system_worker_access
ON products
FOR ALL -- Applies to SELECT, INSERT, UPDATE, DELETE
USING (get_jwt_claim('role') = 'system_worker')
WITH CHECK (get_jwt_claim('role') = 'system_worker');
This approach maintains the integrity of the RLS-first security model. All data access, whether from a user or a system, is governed by policies, and you have a clear audit trail based on the JWTs used.
Performance Considerations
RLS is not free. Every query against an RLS-enabled table incurs the overhead of executing the policy functions.
Let's analyze the impact. Consider our query SELECT * FROM products;.
Without RLS, the plan might be a simple Seq Scan on products.
With RLS, EXPLAIN ANALYZE SELECT * FROM products; will reveal a different plan:
Seq Scan on products (cost=0.00..35.50 rows=1 width=60) (actual time=0.032..0.033 rows=1 loops=1)
Filter: (organization_id = ((current_setting('request.jwt.claims.org_id'::text, true))::uuid))
Rows Removed by Filter: 1
Planning Time: 0.115 ms
Execution Time: 0.053 ms
The key is the Filter line. PostgreSQL has appended our RLS policy as a WHERE clause. The performance implications are therefore identical to having that WHERE clause in your application code.
Optimization Strategies:
organization_id in our case) must be indexed. The RLS filter is fully capable of using this index.USING clause. current_setting() is very fast, but a complex query inside the policy will run for every single row being considered, leading to disastrous performance.STABLE or IMMUTABLE Functions: If you create helper functions for your policies (like our get_jwt_claim), declare them with the appropriate volatility. STABLE indicates the function's result won't change within a single scan, allowing the planner to optimize it.The performance cost is typically negligible compared to the massive security and maintainability benefits. The cost of one developer forgetting one WHERE clause in application code is far higher.
Conclusion: A Robust Foundation for Secure SaaS
By leveraging PostgreSQL RLS with JWT claim propagation, we shift the responsibility of tenancy enforcement from the application developer to the database schema. This creates a secure-by-default posture where it's impossible for a microservice's business logic to accidentally leak data across tenants.
The pattern—JWT -> Middleware -> SET LOCAL in Transaction -> RLS Policy—provides a centralized, auditable, and unbypassable security boundary. While it requires careful setup of middleware and a solid understanding of database transactions and connection pooling, the payoff is a significantly more robust and maintainable multi-tenant architecture. The application code becomes simpler, concerned only with business logic, while the database itself stands as the ultimate guardian of data isolation.