Multi-tenant concerns

In the modern landscape of web applications, the multi-tenant architecture has emerged as a dominant model for delivering software as a service (SaaS). A multi-tenant application is one where a single instance of the web server manages multiple customer organizations—or "tenants"—simultaneously. Each tenant's data and configuration exist in shared infrastructure (shared database) but remain logically isolated from other tenants. This separation is largely enforced by application code, so mistakes or omissions in the separation logic can immediately create very serious data exposure.

Multi-tenant applications are everywhere, and are common across many classes of applications

  • Enterprise systems - Microsoft 360, SAP, etc
  • Customer Relationship Management (CRM) - Salesforce, Hubspot, Zoho
  • Project Management - Asana, Jira, Trello, Basecamp
  • Learnming Management Software - Canvas, Moodle, Blackboard
  • E-Commerce - Shopify, Magento
  • Financial Managament - Quickbooks, Zero, Wave
  • Communication Platforms - Slack, Teams, Discord

Nearly every web application you use that manages data, and has multiple accounts (either organization/team accounts or individual accounts), and is not hosted entirely within the company that has the account, is multi-tenant. Often even when you think you are using an application dedicated to your organization, it's not - it's simply using DNS to give the appearance. For example, if xyz.com is a communication platform, and your company (Acme Corp.) creates an account, you may notice that you interact with it through xyz.acme.com and think it's hosted on your company's infrastructure. In many cases, that URL has been simply routed to a sub-domain on xyz.com, and is part of a multi-tenant installation. The point: multi-tenant architectures are everywhere!

The business advantages of multi-tenancy are clear: development efficiency, simplified maintenance, cost-effective infrastructure, and streamlined updates. However, these benefits come with a profound security responsibility. Unlike single-tenant applications where security breaches affect only one customer, vulnerabilities in multi-tenant systems can potentially expose data across organizational boundaries, turning a single exploit into a catastrophic breach affecting numerous clients.

Multi-tenant security vulnerabilities typically manifest in two forms: malicious attacks and accidental exposures. In targeted attacks, adversaries deliberately attempt to bypass tenant isolation mechanisms to access unauthorized data. These attacks often exploit flaws in authentication systems, authorization checks, or API endpoints where tenant context verification is missing or incomplete. A common technique is parameter tampering, where attackers modify resource identifiers in requests to access another tenant's data—for example, changing a URL from /api/organizations/123/users to /api/organizations/456/users to access user data from a different organization.

Equally concerning are accidental exposures, which occur without malicious intent. These happen when legitimate users inadvertently gain access to another tenant's information due to application flaws. For instance, a user might modify a dropdown selection or follow a bookmark to a resource they previously had access to, only to find themselves viewing another organization's confidential information. These incidents often result from incomplete authorization checks or relying solely on interface restrictions rather than enforcing security at every layer of the application stack.

The consequences of these security failures extend beyond the immediate data breach. Multi-tenant applications typically process sensitive business data, and exposures can trigger contractual violations, compliance penalties, and irreparable reputation damage. For many SaaS providers, a single well-publicized tenant isolation failure can undermine years of trust-building and threaten the business's viability.

In this section, we'll explore the essential security patterns and practices for building robust multi-tenant applications with Node.js and Express. We'll examine middleware approaches for enforcing tenant isolation, techniques for securing routes and API endpoints, and strategies for validating tenant context at every step of the request lifecycle. While we'll briefly touch on database-level security features in PostgreSQL, our primary focus will be on application-level protections that ensure every request is properly constrained to its appropriate tenant context.

The Tenant "Context"

A web multi-tenant application always has the concept of accounts. There are pages within the application that are not tenant-specific, such as the home page, the login screen, and sign-up page flow. Once an account is created, and users can log in, the remaining pages (and routes, in Express) are customized for the specific user. In many multi-tenant architectures, there is an added layer for the organization or company. In this case, individual users do not sign up for the application, but instead organizations and companies create accounts themselves - and add/manage individual users through the application. In both cases however, each URL within the application will have a tenant context - which is just fancy way of saying "who is this?".

For example, let's take a request to /dashboard. This presumably is the home dashboard for an application - but what will be displayed on the dashboard? If the user has logged in, then we will have likely put user information in the session, and we can use the session to understand who this dashboard is for, and what organization this user belongs to - and render the appropriate dashboard. The session, in this case, is serving as the tenant context - it's how we know which user's dashboard to display.

Now let's assume this hypothetical application let's the logged in user see a list of products, perhaps products that their individual company sells. The url is likely something like /products/123 where 123 is the product identifier. The tenant context has significant implication to security here. The session must be examined before serving the request, and we must make sure that product 123 actually belongs to the user's company!

You might wonder, why would a request for a product be generated for a product in an another company? How would that user have known to enter 123 if it weren't in their product list in the first place? This is perhaps the most dangerous mistake web developers make - they forget that an attacker (or simply a error-prone typist) can generate requests for random product ids. Failing to ensure that the logged in user is associated with product 123 could easily result in data leakage!

Often we don't view the session as the only place tenant context is represented. Often organizational accounts receive ID values too, and they appear in the URLs we use, along with user IDs. In this case, the dashboard url might be something more like /2/dashboard/492 where we are rendering a dashboard for user 492 within organization 2. This approach allows us to use the route itself as the tenant context, although we still need to make sure the logged in user is associated with organization 2 and is, indeed, user 492.

The point is that tenant context is just a term we use for the concept of what things the user, and the user's organization, can access. In multi-tenant architectures, it's imperative to ensure every data access verifies - completely - that the entities being accessed and manipulated belong to the correct tenant. Now let's look at how this is commonly done in Express.

Route-Level Security Through Middleware

The first line of defense in multi-tenant applications is middleware that validates every request against the user's permissions and tenant context.

// Middleware to verify tenant access
const verifyTenantAccess = (req, res, next) => {
  const userTenantId = req.session.tenantId;
  const requestedTenantId = req.params.tenantId || req.query.tenantId;
  
  if (!userTenantId) {
    return res.status(401).render('error', { message: 'Not authenticated' });
    
  }
  
  if (requestedTenantId && userTenantId !== requestedTenantId) {
    return res.status(403).render('error', { message: 'Unauthorized access to tenant resources' });
  }
  
  // Add tenant context to request for downstream middleware/routes
  req.tenant = { id: userTenantId };
  next();
};

The above middleware can be attached to any route that must extract the tenant (the company id, organization id, account id, or whatever other identifier being used) from both the session (logged in user) and URL via request parameters or query parameter. This is just an example, you don't always need to use query parameters, and you certainly don't need to use the word tenant - the point is that this middleware can extract the necessary information. If the tenant context isn't available, or doesn't align, then the middleware can return an appropriate error code and page. If the information does align, then the middleware can attach data identifying the tenant to the req object which will be available for downstream request handlers responsible for serving the page.

This middleware might be added to individual routes:

// Example of applying verifyTenantAccess middleware to a specific route
router.get('/dashboard/:tenantId', verifyTenantAccess, (req, res) => {
    // Render the dashboard for the verified tenant
    res.render('dashboard', { tenant: req.tenant });
});

It can also be added to an entire router object, guarding all routes:

// Apply verifyTenantAccess middleware to all routes in the router
router.use(verifyTenantAccess);

// Example routes within the router
router.get('/dashboard', (req, res) => {
    res.render('dashboard', { tenant: req.tenant });
});

router.get('/settings', (req, res) => {
    res.render('settings', { tenant: req.tenant });
});

Resource-Specific Guards

Returning to the product listing example from earlier, it's generally not enough to know the tenant, we also need to make sure that when a request is received for a particular resource (a product), that that product actually belongs to the tenant. Here we use middleware to check that the product ID aligns.

// Middleware to verify user has access to a specific project
const verifyProductAccess = async (req, res, next) => {
  const userId = req.session.userId;

  // We could also check req.tenant, if the tenant middleware attached it
  const tenantId = req.session.tenantId;
  const productId = req.params.productId;
  
  if (!userId || !tenantId || !productId) {
    return res.status(400).json({ error: 'Missing required parameters' });
  }
  
  try {
    // Hypothetical DB access to get the product. 
    const product = await db.get_product(productId);
    
    // Verify that the tenantId associated with the product matches the tenant context
    if (!product || product.tenant != tenantId) {
      return res.status(403).json({ error: 'Access denied to requested product' });
    }

    
    // Attach product to request for later use
    req.product = product;
    next();
  } catch (error) {
    console.error('product access verification failed:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

Now we might attach both middleware, in sequence.

const express = require('express');
const router = express.Router();

// Apply tenant verification to all routes in this router
router.use(verifyTenantAccess);

// Routes with additional specific guards
router.get('/products/:productId', verifyProductAccess, (req, res) => {
    // At this point, we know:
    // 1. User is authenticated
    // 2. User belongs to the correct tenant
    // 3. The requested product belongs to the user's tenant
    // Render the product page with the verified product
    res.render('product', { product: req.product });
});

Database Layer

Securing multi-tenant architectures is more or less all about making sure access to data is limited to users within the correct tenant context for whatever resources they are trying to access. Doing this in code is hard, it's error-prone, and it takes a lot of developer attention. An alternative (or additional) approach is to delegate some or all of this to the database itself.

Row-Level Security (RLS) represents a powerful database feature that enables fine-grained access control at the row level, making it particularly valuable for multi-tenant applications. Unlike traditional access controls that operate at the table or view level, RLS allows databases to filter query results based on user attributes—specifically tenant identifiers in multi-tenant contexts. This capability creates a security boundary directly within the data layer, complementing application-level security measures. This takes significant planning, and can complicate database access code - however it is a very secure method of implementing tenant context protection.

Several major database systems offer robust row-level security features, though implementation details vary:

  • PostgreSQL provides one of the most mature RLS implementations, allowing developers to define security policies that filter rows dynamically based on session variables or user identities.
  • Microsoft SQL Server offers predicate-based security through its RLS feature, controlling which rows users can access using functions that evaluate to true or false.
  • Oracle Database implements Virtual Private Database (VPD) functionality, which predates the term RLS but provides similar capabilities through security policies that automatically append WHERE clauses to queries.
  • Google Cloud Spanner supports row-level security through its fine-grained access control features based on IAM conditions.
  • Amazon Redshift offers RLS policies that filter query results based on user attributes or session variables.

Notable exceptions include MySQL (prior to version 8.0), which lacks native RLS support, and many NoSQL databases where security models differ significantly from traditional row-based approaches.

RLS has many benefits: it provides an additional security layer that operates independently from application code. Even if application-level security contains flaws or vulnerabilities, the database will still enforce tenant isolation. Application code can issue simpler queries without explicitly including tenant filters in every WHERE clause (which is easily forgotten by busy developers). The database automatically applies tenant filters based on the current context. Developers cannot accidentally omit tenant filters since the database enforces them automatically on all operations, reducing the risk of human error. Modern databases optimize RLS implementations to minimize overhead, often integrating security predicates into query execution plans. Many RLS implementations provide built-in audit logs of policy evaluation and access attempts, enhancing security monitoring.

RLS does have some downsides: Implementing and maintaining RLS policies requires specialized database knowledge and careful configuration management. Applications must reliably set the tenant context (typically through session variables) before executing queries, creating a potential failure point. While optimized, RLS still adds computational overhead to query processing, especially for complex policies or high-volume workloads. Testing across tenant boundaries becomes more complex when RLS is enforced, potentially complicating development workflows. Some complex sharing scenarios or cross-tenant functionality may be difficult to model purely through RLS policies. Since RLS implementations vary between database systems, migrating between databases may require significant rework of security models.

Most multi-tenant RLS implementations follow one of three patterns:

  • Session Context Pattern: The application sets a session variable containing the current tenant identifier before executing queries. Database policies then filter rows based on this variable.
  • Function-Based Pattern: Security policies call functions that determine access rights, potentially incorporating complex logic beyond simple tenant matching.
  • User Mapping Pattern: Database users or roles map directly to tenants, with each tenant operating through a dedicated database principal.

Example: Implementing Row-Level Security in PostgreSQL

While the primary focus is on application-level security, PostgreSQL offers powerful row-level security features that complement your Express middleware:

-- Enable row-level security on a table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Create policy that restricts access to rows based on tenant_id
CREATE POLICY tenant_isolation_policy ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

In your Express application, you'd set this context when connecting to the database:

// Set PostgreSQL session variables before executing queries
const setTenantContext = async (client, tenantId) => {
  await client.query(`SET app.tenant_id = $1`, [tenantId]);
};

// Using in an API endpoint
app.get('/api/projects', async (req, res) => {
  const client = await pool.connect();
  try {
    await setTenantContext(client, req.session.tenantId);
    const result = await client.query('SELECT * FROM projects');
    res.json(result.rows);
  } finally {
    client.release();
  }
});