DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Build a Multi-Tenant API in Node.js with PostgreSQL Row-Level Security (2026 Guide)

Building a SaaS API that serves multiple customers from a single codebase is one of the most impactful architectural decisions you'll make. Done right, multi-tenancy lets you scale to thousands of customers with one deployment. Done wrong, it creates data leaks, performance bottlenecks, and debugging nightmares.

This guide walks through production-ready multi-tenant API patterns in Node.js — from tenant identification middleware to PostgreSQL Row-Level Security and connection pool isolation.

What is Multi-Tenancy?

Multi-tenancy means a single instance of your application serves multiple customers (tenants), each with logically isolated data. Think Slack (workspaces), Shopify (stores), or Notion (organizations) — one platform, thousands of isolated environments.

The three core strategies for data isolation are:

Strategy Isolation Level Cost Best For
Shared DB, Shared Schema Row-level Lowest Startups, high tenant count
Shared DB, Schema per Tenant Schema-level Medium Mid-market SaaS
Database per Tenant Full isolation Highest Enterprise, compliance-heavy

In 2026, most SaaS companies start with shared schema + PostgreSQL Row-Level Security (RLS), then migrate high-value enterprise tenants to dedicated schemas or databases.

Step 1: Tenant Identification Middleware

Every API request must be mapped to a tenant before any business logic runs. There are three common identification strategies:

Subdomain routing: acme.yourapi.com → tenant: acme

Header-based: X-Tenant-ID: acme

JWT claim: decoded token contains tenantId

Here's a robust Express middleware that supports all three:

// middleware/tenantContext.js
import { AsyncLocalStorage } from 'node:async_hooks';

// Thread-local equivalent for Node.js — tenant context flows through
// all async calls within a request without prop drilling
export const tenantStorage = new AsyncLocalStorage();

export function resolveTenant(tenantRepo) {
  return async (req, res, next) => {
    let tenantId;

    // Priority: JWT claim > Header > Subdomain
    if (req.user?.tenantId) {
      tenantId = req.user.tenantId;
    } else if (req.headers['x-tenant-id']) {
      tenantId = req.headers['x-tenant-id'];
    } else {
      const host = req.hostname; // e.g. "acme.yourapi.com"
      const subdomain = host.split('.')[0];
      if (subdomain && subdomain !== 'api' && subdomain !== 'www') {
        tenantId = subdomain;
      }
    }

    if (!tenantId) {
      return res.status(400).json({ error: 'Tenant context required' });
    }

    // Load tenant from cache or DB
    const tenant = await tenantRepo.findById(tenantId);
    if (!tenant || tenant.status !== 'active') {
      return res.status(403).json({ error: 'Tenant not found or inactive' });
    }

    // Store tenant context in AsyncLocalStorage — accessible anywhere downstream
    tenantStorage.run({ tenant }, () => next());
  };
}

// Accessor — call this anywhere in your service/repo layer
export function getCurrentTenant() {
  const store = tenantStorage.getStore();
  if (!store?.tenant) throw new Error('No tenant context — called outside request scope');
  return store.tenant;
}
Enter fullscreen mode Exit fullscreen mode

AsyncLocalStorage is the Node.js equivalent of thread-local storage. It propagates your tenant context through all async callbacks and promise chains within a single request — no need to pass tenantId as a parameter through every function.

Step 2: PostgreSQL Row-Level Security

RLS is the safest way to enforce tenant isolation at the database level. Even if your application code has a bug that forgets to filter by tenant_id, Postgres will block cross-tenant data access automatically.

Database Setup

-- Enable RLS on your tenant-aware tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

-- Policy: only show rows that match the current tenant
CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- A low-privilege role that app connections use
CREATE ROLE app_user LOGIN PASSWORD 'strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- app_user cannot bypass RLS (unlike superuser)
Enter fullscreen mode Exit fullscreen mode

Application Connection Setup

// db/tenantPool.js
import pg from 'pg';
import { getCurrentTenant } from '../middleware/tenantContext.js';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  user: 'app_user', // non-superuser; RLS enforced
  max: 20,
  idleTimeoutMillis: 30000,
});

// Wrap pool.query to automatically set tenant context before each query
export async function query(sql, params) {
  const tenant = getCurrentTenant();
  const client = await pool.connect();

  try {
    // Set the tenant for this transaction — Postgres RLS uses this
    await client.query(
      `SET LOCAL app.current_tenant = $1`,
      [tenant.id]
    );
    const result = await client.query(sql, params);
    return result;
  } finally {
    client.release();
  }
}

// For explicit transactions
export async function withTransaction(fn) {
  const tenant = getCurrentTenant();
  const client = await pool.connect();

  try {
    await client.query('BEGIN');
    await client.query(`SET LOCAL app.current_tenant = $1`, [tenant.id]);
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Critical detail: Use SET LOCAL (not SET) so the tenant context is transaction-scoped and automatically resets when the connection returns to the pool. Using SET would persist the tenant ID to the next request that reuses the connection — a silent data leak.

Step 3: Tenant-Aware Route Structure

With the middleware and DB layer in place, your route handlers become clean and tenant-agnostic:

// routes/orders.js
import { Router } from 'express';
import { query, withTransaction } from '../db/tenantPool.js';
import { getCurrentTenant } from '../middleware/tenantContext.js';

const router = Router();

// GET /orders — automatically filtered by RLS
router.get('/', async (req, res) => {
  const { rows } = await query(
    `SELECT * FROM orders WHERE status = $1 ORDER BY created_at DESC LIMIT 50`,
    [req.query.status || 'all']
    // No tenant_id filter needed here — RLS handles it
  );
  res.json(rows);
});

// POST /orders
router.post('/', async (req, res) => {
  const tenant = getCurrentTenant();
  const { product_id, quantity } = req.body;

  const result = await withTransaction(async (client) => {
    return client.query(
      `INSERT INTO orders (tenant_id, product_id, quantity, created_at)
       VALUES ($1, $2, $3, NOW()) RETURNING *`,
      [tenant.id, product_id, quantity]
    );
  });

  res.status(201).json(result.rows[0]);
});

export default router;
Enter fullscreen mode Exit fullscreen mode

Notice: no WHERE tenant_id = ? in the SELECT query. RLS filters it for you. The code reads like a single-tenant app but behaves like a multi-tenant one.

Step 4: Tenant Caching to Avoid DB Roundtrips

Loading tenant data from the database on every request is expensive. Cache it in Redis with a short TTL:

// repos/tenantRepo.js
import { createClient } from 'redis';
import { pool } from '../db/tenantPool.js';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const CACHE_TTL = 300; // 5 minutes

export const tenantRepo = {
  async findById(tenantId) {
    const cacheKey = `tenant:${tenantId}`;

    // Check cache first
    const cached = await redis.get(cacheKey);
    if (cached) return JSON.parse(cached);

    // Fall back to DB
    const { rows } = await pool.query(
      `SELECT id, slug, name, plan, status, config
       FROM tenants WHERE id = $1 OR slug = $1`,
      [tenantId]
    );

    if (rows[0]) {
      await redis.setEx(cacheKey, CACHE_TTL, JSON.stringify(rows[0]));
    }

    return rows[0] || null;
  },

  async invalidate(tenantId) {
    await redis.del(`tenant:${tenantId}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Step 5: Per-Tenant Rate Limiting

Rate limits should be enforced per tenant, not per IP. A shared IP (corporate NAT) shouldn't affect other users on the same tenant, and a single bad tenant shouldn't degrade your platform:

// middleware/tenantRateLimit.js
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

// Plan-based limits
const PLAN_LIMITS = {
  free:       { requests: 100,  window: 60 },  // 100 req/min
  pro:        { requests: 1000, window: 60 },  // 1000 req/min
  enterprise: { requests: 10000, window: 60 }, // 10k req/min
};

export function tenantRateLimit() {
  return async (req, res, next) => {
    const tenant = req.tenant; // set by resolveTenant middleware
    const limits = PLAN_LIMITS[tenant.plan] || PLAN_LIMITS.free;

    const key = `ratelimit:${tenant.id}:${Math.floor(Date.now() / (limits.window * 1000))}`;
    const count = await redis.incr(key);

    if (count === 1) {
      await redis.expire(key, limits.window);
    }

    res.setHeader('X-RateLimit-Limit', limits.requests);
    res.setHeader('X-RateLimit-Remaining', Math.max(0, limits.requests - count));

    if (count > limits.requests) {
      return res.status(429).json({
        error: 'Rate limit exceeded',
        retryAfter: limits.window,
        plan: tenant.plan,
        upgradeUrl: 'https://1xapi.com/pricing'
      });
    }

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Schema-Per-Tenant for Enterprise Isolation

For enterprise customers requiring stronger isolation (HIPAA, SOC 2, GDPR data residency), use schema-per-tenant alongside RLS:

// db/schemaPool.js — for enterprise tenants with dedicated schemas
import pg from 'pg';

const pools = new Map();

export function getTenantPool(tenant) {
  if (!pools.has(tenant.id)) {
    const pool = new pg.Pool({
      connectionString: process.env.DATABASE_URL,
    });

    // Override connect to set schema search path
    const originalConnect = pool.connect.bind(pool);
    pool.connect = async () => {
      const client = await originalConnect();
      await client.query(`SET search_path TO "${tenant.schema}", public`);
      return client;
    };

    pools.set(tenant.id, pool);
  }

  return pools.get(tenant.id);
}
Enter fullscreen mode Exit fullscreen mode

For most startups, shared schema + RLS is the right starting point. Migrate enterprise customers to dedicated schemas when their contracts and compliance requirements justify it.

Putting It All Together

Here's how the full middleware stack assembles:

// app.js
import express from 'express';
import { resolveTenant } from './middleware/tenantContext.js';
import { tenantRateLimit } from './middleware/tenantRateLimit.js';
import { authenticate } from './middleware/auth.js';
import { tenantRepo } from './repos/tenantRepo.js';
import ordersRouter from './routes/orders.js';

const app = express();
app.use(express.json());

// Auth first — sets req.user with JWT claims
app.use(authenticate);

// Resolve tenant from JWT claim, header, or subdomain
app.use(resolveTenant(tenantRepo));

// Rate limiting per tenant plan
app.use(tenantRateLimit());

// Your routes — automatically tenant-scoped via RLS
app.use('/orders', ordersRouter);

// Global error handler
app.use((err, req, res, next) => {
  const tenant = req.tenant?.id || 'unknown';
  console.error(`[${tenant}] Error:`, err.message);
  res.status(500).json({ error: 'Internal server error' });
});

export default app;
Enter fullscreen mode Exit fullscreen mode

Key Pitfalls to Avoid

1. Using SET instead of SET LOCAL for tenant context

SET persists across connection pool reuse. Always use SET LOCAL inside a transaction.

2. Bypassing RLS with superuser connections

PostgreSQL superusers and table owners bypass RLS by default. Use a non-privileged app_user role for all application queries.

3. Forgetting to invalidate tenant cache on plan changes

When a tenant upgrades plans, their cached data is stale. Emit a tenant.updated event and call tenantRepo.invalidate(tenantId).

4. Sharing connection pools across tenants in schema-per-tenant mode

Schema search_path is connection-scoped in Postgres. If you share a pool across tenants without resetting it, queries land in the wrong schema.

5. Rate limiting by IP instead of tenant

Corporate networks NAT thousands of users through one IP. Always rate-limit by tenantId.

Performance at Scale

In 2026, well-tuned multi-tenant Node.js APIs routinely serve 10,000+ tenants from a single deployment. Key benchmarks to target:

  • Tenant context resolution: < 1ms (Redis cache hit)
  • RLS overhead vs. application-level filtering: < 5% (negligible at p99)
  • Connection pool size: 20-50 connections per app instance, shared across all tenants

With Drizzle ORM or Prisma, you can wrap the tenant-scoped query function above and get full type safety on top of the RLS enforcement layer.

Wrapping Up

Multi-tenancy isn't a feature — it's a foundational architectural choice. The pattern stack covered here (AsyncLocalStorage → tenant middleware → PostgreSQL RLS → per-tenant rate limiting) has been proven at companies like Supabase, PlanetScale, and Neon, which power multi-tenant data infrastructure for thousands of SaaS products.

Start with shared schema + RLS. Keep your per-tenant cache warm. Enforce rate limits by plan. When an enterprise customer needs dedicated isolation, you have a clear migration path to schema-per-tenant or database-per-tenant without rewriting your application layer.


Need a production API to test against? Check out 1xAPI on RapidAPI for sports data, verification, and utility APIs with developer-friendly rate limits and clear tenant separation.

Top comments (0)