PostgreSQL RLS + AsyncLocalStorage: Defense-in-Depth for SaaS
Application-layer filtering is a convenience. RLS is a guarantee. The trick is propagating the tenant context from the HTTP request all the way down to the database session — without passing it as a parameter to every single query.
The pattern: AsyncLocalStorage + RLS
Node's `AsyncLocalStorage` lets you set a tenant context once per request and have it automatically available anywhere in the async call chain — including deep inside ORM methods that don't know about tenants.
import { AsyncLocalStorage } from 'async_hooks';
const tenantStorage = new AsyncLocalStorage<string>();
// NestJS interceptor — runs once per request
@Injectable()
export class TenantInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
const tenantId = req.user.tenant_id;
return tenantStorage.run(tenantId, () => next.handle());
}
}
// Anywhere in the codebase — no parameters needed
function getCurrentTenant(): string {
return tenantStorage.getStore() ?? throw new Error('No tenant context');
}The RLS policy
On the database side, every tenant-scoped table gets an RLS policy:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- The app connects as a non-superuser role so RLS applies
ALTER ROLE tenantkit_app NOBYPASSRLS;Bridging the two
The bridge is a TypeORM listener that runs at the start of every transaction:
@EventSubscriber()
export class TenantSubscriber implements EntitySubscriberInterface {
beforeTransactionStart(event: TransactionStartEvent) {
const tenantId = getCurrentTenant();
event.queryRunner.query(
`SET LOCAL ROLE tenantkit_app; SELECT set_config('app.current_tenant', $1, true)`,
[tenantId]
);
}
}Now every query on `documents` is automatically filtered by `tenant_id` — even if the developer forgets to add a WHERE clause. RLS catches it.
The payoff
- **Zero cross-tenant leakage** even when developers forget WHERE clauses
- **No performance overhead** — RLS uses the same index as the WHERE clause
- **Defense-in-depth** — application-layer filtering + RLS = two independent checks
The lesson: tenant isolation is a database problem, not an application problem. Push it as deep as you can.