TRENDING
A mountain gorilla looks at the camera from green forest undergrowth
October 29, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
October 29, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
October 29, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 29, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 29, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
29 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A flock of snow geese in flight, seen as dark silhouettes against an orange and red sunset sky with layered clouds
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
An old green steel truss bridge resting on temporary supports beside a new arch bridge under completion on a river in Portland, Oregon
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
Hundreds of yellow rubber ducks floating in a river enclosure during a duck race
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 270 Posts
News 273 Posts
Learning Hub 243 Posts
Home/Learning Hub/How to Build a Multi-Tenant SaaS API With Node.js, RBAC, and Audit Logging
Learning Hub

How to Build a Multi-Tenant SaaS API With Node.js, RBAC, and Audit Logging

Learn how to build a multi-tenant Node.js API with row-level tenant isolation, role-based access control, an append-only audit log, and per-tenant Redis rate limiting, verified end to end with real...

July 22, 2026 22 Min Read
105

A colleague once asked one of our team to look at a “permissions bug” in a SaaS project management tool. Users on one company’s account were seeing projects they had never created. The cause was not subtle once someone looked: the query behind the project list had no tenant_id filter at all. Every customer in the database could read every other customer’s rows. Nothing crashed. Nothing logged an error. The application simply returned whatever was there, which is the most dangerous way a multi-tenant system can fail: silently, in production, for however long it takes someone to notice.

Table Of Content

  • What You Will Build, and Why Each Piece Matters
  • Three Ways to Isolate Tenants
  • RBAC, Audit Logging, and Rate Limiting, Defined
  • The Request Pipeline You Will Build
  • Prerequisites
  • Step 1: Install PostgreSQL and Redis
  • Step 2: Create the Database and a Least-Privilege Application Role
  • Step 3: Design a Tenant-Isolated, Compliance-Aware Schema
  • Step 4: Initialize the Node.js Project and Install Dependencies
  • Step 5: Connect to PostgreSQL and Redis
  • Step 6: Build JWT Authentication Middleware
  • Step 7: Build the RBAC Middleware
  • Step 8: Build the Tenant-Safe Repository Layer
  • Step 9: Build the Append-Only Audit Logging Service
  • Step 10: Add Per-Tenant Rate Limiting With Redis, and a Real Bug to Watch For
  • Step 11: Wire Up the Routes in the Correct Middleware Order
  • Step 12: Seed Test Tenants and Start the Server
  • Step 13: Verify Tenant Isolation, RBAC, Audit Logging, and Rate Limiting End to End
  • Common Mistakes and Gotchas
  • Reading tenant_id from anywhere other than the verified JWT
  • Rate limiting before authentication
  • The express-rate-limit v8+ IPv6 validation error
  • ON DELETE CASCADE on an append-only audit table
  • Bypassing the repository layer with a raw query
  • How to Confirm Everything Works End to End
  • Next Steps

This tutorial teaches you how to build a small but real multi-tenant REST API in Node.js that closes that entire class of bug by construction. You will build tenant isolation, role-based access control (RBAC), an append-only audit log, and per-tenant rate limiting, and you will verify all four of them actually work by attacking your own API with real HTTP requests before you finish. Every command, every file, and every test result in this guide was run on a live machine while writing it: a real PostgreSQL 18 database and a real Redis instance, both installed fresh, a real Node.js API built from scratch, and a real end-to-end test where two fake companies try to see each other’s data and fail. Two real bugs turned up during that process, one in a security-critical library and one in the schema design, and both are covered below with the fix, not just the finished code.

This guide targets Ubuntu 26.04 LTS. The same Node.js code runs unmodified on macOS and Windows; only the PostgreSQL and Redis installation commands in Step 1 differ, and official links are given for both.

What You Will Build, and Why Each Piece Matters

Before the first command, it helps to define the four ideas this tutorial is built around, since the rest of the guide assumes you know what each one means.

Multi-tenancy means one running copy of your application serves many separate customers (“tenants”) whose data must never mix. Tenant isolation is the guarantee that enforces this: every query a tenant’s request can trigger is scoped to that tenant, so no code path can accidentally return another tenant’s rows.

Three Ways to Isolate Tenants

  • Row-level isolation: one shared database and one shared set of tables, with a tenant_id column on every tenant-owned table and a WHERE tenant_id = ... filter on every query. This is what this tutorial builds.
  • Schema-per-tenant: one database, one PostgreSQL schema per tenant. Stronger isolation, but migrations and connection management get more complex as the tenant count grows.
  • Database-per-tenant: the strongest isolation, at the cost of a connection pool and an operational burden that grows with every new signup.

Row-level isolation is the right default for most SaaS products: it scales to a large number of tenants on ordinary infrastructure, and the discipline it requires (never trust a client-supplied tenant ID, always filter by the one from a verified credential) is exactly the discipline a well-built multi-tenant system needs anyway.

RBAC, Audit Logging, and Rate Limiting, Defined

RBAC (role-based access control) means access decisions are based on a role assigned to a user (Viewer, Member, and so on) rather than checking each user individually. This tutorial uses four roles with a numeric level each, so “does this user have at least Member access” is a single integer comparison.

Audit logging means recording who did what, to which resource, and when, in a log that the application itself cannot alter after the fact. This matters for more than curiosity: a SOC 2 or similar compliance review will ask for evidence of who accessed or changed sensitive data, and “the application could have quietly rewritten that history” is not an acceptable answer.

Rate limiting caps how many requests a caller can make in a given time window. In a multi-tenant system, the limit has to key off the tenant, not the caller’s IP address; otherwise fifty employees of one large customer sitting behind a single corporate proxy can look like one IP hammering your API, while a single abusive tenant spread across many IPs sails through untouched.

The Request Pipeline You Will Build

The architecture you will build wires these together as a chain of Express middleware that runs before any route handler sees a request:

HTTP Request
     |
     v
1. Auth middleware       verifies the JWT, extracts userId / tenantId / role
2. Tenant rate limiter    counts this request against the tenant's quota
3. RBAC middleware        checks the role against the route's minimum
     |
     v
Route handler  -->  Tenant-safe repository (query always scoped to tenantId)
     |
     +-->  Audit service (fire-and-forget, on writes)

Note the order: the rate limiter has to run after authentication, not before, because it needs the verified tenantId to know which bucket to count against. It is easy to draw this diagram with rate limiting first out of habit (it is, after all, the outermost concern); Step 10 covers what actually breaks if you build it that way.

Prerequisites

  • A Linux server or desktop with sudo/root access. This guide was run on Ubuntu 26.04 LTS; commands are noted where another distribution differs.
  • Node.js 18 or newer. This guide was written and tested against Node.js v24.18.0.
  • Comfort with the command line, and basic familiarity with Express.js and JSON Web Tokens (JWT). No prior multi-tenant, RBAC, or audit-logging experience is assumed; every concept is explained the first time it is used.
  • curl for testing the API from the command line.
  • No cloud account and no paid service is required. Everything in this tutorial runs on a single local machine.

Step 1: Install PostgreSQL and Redis

The API needs two backing services: PostgreSQL to store tenants, users, projects, and the audit log, and Redis to back the per-tenant rate limiter with a counter that survives a server restart. On Ubuntu or Debian, both are available directly from the default package repository:

# Context: Ubuntu 26.04 LTS / Debian, run as root or with sudo.
# Purpose: install PostgreSQL and Redis from the distribution's own repository.
apt-get update
apt-get install -y postgresql redis-server

Start both services and confirm they are active:

service postgresql start
service redis-server start
service postgresql status
service redis-server status

Expected output (trimmed): both status commands should show Active: active, and for Redis specifically the log line Ready to accept connections. This tutorial was run against PostgreSQL 18.4 and Redis 8.x as packaged for Ubuntu 26.04; PostgreSQL 13 or newer is all this schema actually requires, since it depends only on the built-in gen_random_uuid() function added in that release.

On macOS, brew install postgresql redis followed by brew services start postgresql and brew services start redis does the equivalent. On Windows, use the official installers linked from postgresql.org and redis.io, or run both inside Windows Subsystem for Linux using the Ubuntu commands above.

Confirm Redis answers before moving on:

redis-cli ping

Expected output: PONG. If you get Could not connect instead, Redis is not running; re-check service redis-server status for an error before continuing.

Step 2: Create the Database and a Least-Privilege Application Role

The API should never connect to PostgreSQL as the superuser. Create a dedicated role and database for it:

# Context: PostgreSQL 18.4 on Ubuntu 26.04, run as the postgres OS user.
# Purpose: create an application-only database role and its database.
su postgres -c "psql -c \"CREATE USER app_user WITH PASSWORD 'app_password';\""
su postgres -c "psql -c \"CREATE DATABASE saas_api OWNER app_user;\""

Expected output: CREATE ROLE then CREATE DATABASE. Use a real, randomly generated password outside of a local test environment; app_password here is a placeholder for a guide you will run on your own machine.

This role owning its own database, rather than connecting as the postgres superuser, is what makes the privilege restriction in Step 3 meaningful. A superuser bypasses every permission check PostgreSQL has; an ordinary owner does not, which is the property the audit log’s immutability depends on.

Step 3: Design a Tenant-Isolated, Compliance-Aware Schema

Create a schema.sql file with four tables:

-- schema.sql
-- Tenants table
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  plan VARCHAR(50) NOT NULL DEFAULT 'free',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users table
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(50) NOT NULL DEFAULT 'Member',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects table (the example tenant-owned resource)
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name VARCHAR(255) NOT NULL,
  description TEXT,
  created_by UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Audit log table (append-only). Deliberately NOT ON DELETE CASCADE from
-- tenants: an audit trail should survive tenant deletion, not vanish with it.
CREATE TABLE audit_logs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  actor_user_id UUID REFERENCES users(id),
  action VARCHAR(100) NOT NULL,
  resource_type VARCHAR(100) NOT NULL,
  resource_id UUID,
  metadata JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id, created_at DESC);

Three design choices are worth explaining rather than just copying:

Every tenant-owned table has a tenant_id column, and nothing else identifies ownership. The repository layer in Step 8 will always filter by this column, taken only from a verified JWT, never from anything the client can influence directly.

The audit_logs.tenant_id foreign key intentionally has no ON DELETE CASCADE, unlike users.tenant_id and projects.tenant_id. If a tenant is deleted, the audit history explaining what that tenant did should not disappear along with it; a compliance-grade log needs to outlive the account that generated it. In practice this means tenant deletion needs its own explicit, deliberate procedure (commonly a soft delete, marking the tenant inactive rather than removing the row) instead of a plain DELETE FROM tenants, and that is a deliberate feature of this design, not an oversight.

The audit log stores metadata as JSONB so each action can carry a small, structured, action-specific payload (which fields changed, for example) without a schema migration every time you want to log one more detail.

Apply the schema and lock down the audit table:

PGPASSWORD=app_password psql -h 127.0.0.1 -U app_user -d saas_api -f schema.sql
PGPASSWORD=app_password psql -h 127.0.0.1 -U app_user -d saas_api -c "REVOKE UPDATE, DELETE ON audit_logs FROM app_user;"

Expected output: a CREATE TABLE / CREATE INDEX line for each statement in the file, then REVOKE.

That REVOKE is the actual enforcement mechanism behind “append-only,” and it is worth confirming it does what it claims rather than taking it on faith. app_user owns this table, so it is reasonable to wonder whether an owner can bypass its own restriction. Testing it directly answers that:

PGPASSWORD=app_password psql -h 127.0.0.1 -U app_user -d saas_api -c "
CREATE TABLE revoke_test (id serial primary key, val text);
INSERT INTO revoke_test (val) VALUES ('original');
REVOKE UPDATE, DELETE ON revoke_test FROM app_user;
"
PGPASSWORD=app_password psql -h 127.0.0.1 -U app_user -d saas_api -c "UPDATE revoke_test SET val = 'tampered' WHERE id = 1;"

Expected output: ERROR: permission denied for table revoke_test. PostgreSQL enforces an explicit REVOKE even against the role that owns the table (only a superuser bypasses permission checks entirely), so once you revoke UPDATE and DELETE from app_user on audit_logs, the running application, which connects as app_user, genuinely cannot modify or erase a row after inserting it. A row can still be inserted (that privilege was never revoked), but nothing in the application can change one afterward. This is the same mechanism you will see confirmed again with real audit rows in Step 13. Clean up the test table before continuing: DROP TABLE revoke_test; (as a superuser, since app_user can no longer be trusted to run arbitrary cleanup on tables with this restriction pattern).

Step 4: Initialize the Node.js Project and Install Dependencies

# Context: any machine with Node.js 18+ and npm installed.
# Purpose: scaffold the project and install every runtime dependency this API uses.
mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv

Expected output: added 107 packages (the exact count can vary slightly by npm version) and found 0 vulnerabilities. This tutorial was built and tested against these exact versions: Express 5.2.1, pg 8.22.0, jsonwebtoken 9.0.3, bcryptjs 3.0.3, express-rate-limit 8.6.0, rate-limit-redis 6.0.0, ioredis 5.11.1, and dotenv 17.4.2. The express-rate-limit major version matters more than it looks like it should; Step 10 explains a real error that only shows up on v8.0.0 and later.

Create a .env file for local configuration:

# .env
PORT=3000
DATABASE_URL=postgres://app_user:[email protected]:5432/saas_api
REDIS_URL=redis://127.0.0.1:6379
JWT_SECRET=dev-only-secret-change-me

Generate a real random value for JWT_SECRET outside of local testing (for example, openssl rand -base64 48), and never commit .env to version control.

Step 5: Connect to PostgreSQL and Redis

Create db.js, a single shared connection pool for PostgreSQL:

// db.js
require('dotenv').config();
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

module.exports = { pool };

A Pool, rather than one long-lived client connection, hands out and reuses a small set of underlying database connections across concurrent requests, which is what lets one Node.js process serve many simultaneous callers without them queuing behind a single connection.

Create redisClient.js the same way:

// redisClient.js
require('dotenv').config();
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

module.exports = { redisClient };

Step 6: Build JWT Authentication Middleware

Create roles.js to define the role hierarchy as numeric levels:

// roles.js
const ROLE_LEVELS = {
  Viewer: 1,
  Member: 2,
  TenantAdmin: 3,
  SuperAdmin: 4,
};

function roleAtLeast(role, minRole) {
  return (ROLE_LEVELS[role] || 0) >= (ROLE_LEVELS[minRole] || Infinity);
}

module.exports = { ROLE_LEVELS, roleAtLeast };

Each role in this tutorial is scoped to one tenant: a SuperAdmin at Acme Corp has no special access at Globex Inc, since every role check happens after the request has already been bound to exactly one tenant. Now create middleware/auth.js:

// middleware/auth.js
const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;

  if (!token) {
    return res.status(401).json({ error: 'Missing bearer token' });
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = {
      id: payload.sub,
      tenantId: payload.tenantId,
      role: payload.role,
    };
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authenticate };

This is the single most important line of code in the whole tutorial for security purposes: tenantId: payload.tenantId takes the tenant ID only from inside a JWT that jwt.verify has just cryptographically confirmed was signed by this server. It is never read from req.body, a URL parameter, or a header the caller sent directly. A client can lie in a request body; a client cannot forge a valid signature for a token claiming a tenantId it was never issued, because it does not have JWT_SECRET. Every tenant-isolation guarantee in this tutorial depends on that one design decision holding everywhere, with no exceptions.

Step 7: Build the RBAC Middleware

// middleware/rbac.js
const { roleAtLeast } = require('../roles');

function requireRole(minRole) {
  return (req, res, next) => {
    if (!req.user || !roleAtLeast(req.user.role, minRole)) {
      return res.status(403).json({ error: 'Insufficient role for this action' });
    }
    next();
  };
}

module.exports = { requireRole };

requireRole('Member') returns a middleware function, so each route can declare its own minimum role inline (you will see this in Step 11) without repeating the comparison logic. Because this runs after authenticate, req.user is always present and trustworthy by the time this check runs; the two middlewares are meant to be used together, in that order.

Step 8: Build the Tenant-Safe Repository Layer

A repository layer is a thin module that owns all the SQL for one resource, so route handlers never write raw queries themselves and can never forget a tenant_id filter by accident:

// repositories/projectRepository.js
const { pool } = require('../db');

async function listProjects(tenantId) {
  const { rows } = await pool.query(
    'SELECT id, name, description, created_by, created_at FROM projects WHERE tenant_id = $1 ORDER BY created_at DESC',
    [tenantId]
  );
  return rows;
}

async function createProject(tenantId, createdBy, { name, description }) {
  const { rows } = await pool.query(
    `INSERT INTO projects (tenant_id, name, description, created_by)
     VALUES ($1, $2, $3, $4)
     RETURNING id, name, description, created_by, created_at`,
    [tenantId, name, description || null, createdBy]
  );
  return rows[0];
}

async function getProjectById(tenantId, projectId) {
  const { rows } = await pool.query(
    'SELECT id, name, description, created_by, created_at FROM projects WHERE tenant_id = $1 AND id = $2',
    [tenantId, projectId]
  );
  return rows[0] || null;
}

module.exports = { listProjects, createProject, getProjectById };

Look closely at getProjectById: it filters by both tenant_id and id, rather than looking up the project by id alone and checking the tenant afterward. This means a request for a project that exists, but belongs to a different tenant, returns zero rows from the database itself. The route handler in Step 11 turns that into a plain 404 Not Found, identical to what a genuinely nonexistent ID would return. A caller who tries a valid project ID from another tenant learns nothing: not that the ID is valid, not that it belongs to someone else, nothing. All parameters are also passed as placeholders ($1, $2, and so on) rather than interpolated into the SQL string, which is what prevents SQL injection through any of these fields.

Step 9: Build the Append-Only Audit Logging Service

// services/auditService.js
const { pool } = require('../db');

async function recordAudit({ tenantId, actorUserId, action, resourceType, resourceId, metadata }) {
  try {
    await pool.query(
      `INSERT INTO audit_logs (tenant_id, actor_user_id, action, resource_type, resource_id, metadata)
       VALUES ($1, $2, $3, $4, $5, $6)`,
      [tenantId, actorUserId, action, resourceType, resourceId || null, metadata ? JSON.stringify(metadata) : null]
    );
  } catch (err) {
    console.error('audit log write failed', err.message);
  }
}

module.exports = { recordAudit };

This is a deliberately “fire and forget” design: a failure writing the audit row is logged to the server’s own console, but it does not throw, and it does not fail the user’s request. That trade-off is intentional for a tutorial-scoped project. Weigh it against your own compliance requirements before using this pattern as-is: if your audit trail must be complete with no gaps, a failed audit write should arguably fail (or queue and retry) the action it was supposed to record, not silently let the action through uncounted.

Step 10: Add Per-Tenant Rate Limiting With Redis, and a Real Bug to Watch For

// middleware/tenantRateLimiter.js
const rateLimit = require('express-rate-limit');
const { ipKeyGenerator } = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../redisClient');

const tenantRateLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 5,
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) => req.user?.tenantId || ipKeyGenerator(req.ip),
  store: new RedisStore({
    sendCommand: (...args) => redisClient.call(...args),
    prefix: 'rl:tenant:',
  }),
});

module.exports = { tenantRateLimiter };

windowMs: 60 * 1000, limit: 5 allows five requests per tenant per rolling 60-second window; adjust both numbers to whatever your API actually needs; five was chosen here only to make the limit easy to hit and demonstrate in Step 13. keyGenerator is what makes this a per-tenant limiter rather than the library’s IP-based default: every request from any user at the same tenant shares one bucket, which is what Step 13 confirms.

The fallback branch, ipKeyGenerator(req.ip), is worth pausing on, because writing it the obvious way, req.user?.tenantId || req.ip, fails on startup with express-rate-limit 8.x:

ValidationError: Custom keyGenerator appears to use request IP without calling the ipKeyGenerator helper function for IPv6 addresses. This could allow IPv6 users to bypass limits.
    code: 'ERR_ERL_KEY_GEN_IPV6'

As of version 8.0.0, express-rate-limit validates any custom keyGenerator that appears to fall back to a raw IP address, because an IPv6 address alone is the wrong granularity to rate-limit by: a single client can typically be handed a huge range of IPv6 addresses by its ISP, so keying on the exact address lets it cycle through addresses to dodge the limit entirely. The library ships an ipKeyGenerator helper that normalizes an address to a fixed-size subnet before using it as a key, and it wants you to route any IP-based fallback through that helper explicitly, which is exactly what the corrected line above does. This only ever matters for the fallback path (an authenticated request always has a tenantId and never reaches it), but the library validates the function’s source at startup regardless of whether that branch will run, so the fix is required even in a fully authenticated API like this one.

Step 11: Wire Up the Routes in the Correct Middleware Order

// routes/auth.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { pool } = require('../db');

const router = express.Router();

router.post('/login', async (req, res) => {
  const { tenantId, email, password } = req.body;

  if (!tenantId || !email || !password) {
    return res.status(400).json({ error: 'tenantId, email, and password are required' });
  }

  const { rows } = await pool.query(
    'SELECT id, tenant_id, password_hash, role FROM users WHERE tenant_id = $1 AND email = $2',
    [tenantId, email]
  );
  const user = rows[0];

  if (!user || !(await bcrypt.compare(password, user.password_hash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = jwt.sign(
    { sub: user.id, tenantId: user.tenant_id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );

  res.json({ token });
});

module.exports = router;

A production login endpoint would typically resolve the tenant from a subdomain or an invite link rather than asking the client to supply a tenantId directly; this tutorial accepts it as a plain field to keep the login flow focused on the JWT-issuing logic that matters for the rest of the guide. Either way, note that this is the only place in the whole application where a client-supplied tenant identifier is trusted, and only to look up a password hash to check against, never to scope a data query directly.

// routes/projects.js
const express = require('express');
const { authenticate } = require('../middleware/auth');
const { requireRole } = require('../middleware/rbac');
const { tenantRateLimiter } = require('../middleware/tenantRateLimiter');
const projectRepository = require('../repositories/projectRepository');
const { recordAudit } = require('../services/auditService');

const router = express.Router();

router.use(authenticate);
router.use(tenantRateLimiter);

router.get('/', requireRole('Viewer'), async (req, res) => {
  const projects = await projectRepository.listProjects(req.user.tenantId);
  res.json({ projects });
});

router.post('/', requireRole('Member'), async (req, res) => {
  const { name, description } = req.body;
  if (!name) {
    return res.status(400).json({ error: 'name is required' });
  }

  const project = await projectRepository.createProject(req.user.tenantId, req.user.id, { name, description });

  await recordAudit({
    tenantId: req.user.tenantId,
    actorUserId: req.user.id,
    action: 'project.create',
    resourceType: 'project',
    resourceId: project.id,
    metadata: { name },
  });

  res.status(201).json({ project });
});

router.get('/:id', requireRole('Viewer'), async (req, res) => {
  const project = await projectRepository.getProjectById(req.user.tenantId, req.params.id);
  if (!project) {
    return res.status(404).json({ error: 'Project not found' });
  }
  res.json({ project });
});

module.exports = router;

router.use(authenticate) then router.use(tenantRateLimiter) is the order established back in the architecture diagram, and it is not interchangeable. If the rate limiter ran first, it would reach every request, including ones with no valid token yet, before req.user.tenantId exists, and it would have nothing to key on but a raw IP, the exact problem per-tenant limiting is meant to avoid. Running authenticate first guarantees req.user.tenantId is always populated by the time the rate limiter’s keyGenerator runs.

Also notice that the rate limiter runs before requireRole on each route. That means a request that will ultimately be rejected for having the wrong role still counts against the tenant’s rate-limit quota; Step 13 shows this directly. This is deliberate: rate limiting exists to protect backend capacity from being overwhelmed by request volume, a concern that applies regardless of whether any individual request turns out to be authorized.

Finally, server.js ties it all together:

// server.js
require('dotenv').config();
const express = require('express');
const authRoutes = require('./routes/auth');
const projectRoutes = require('./routes/projects');

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

app.use('/auth', authRoutes);
app.use('/projects', projectRoutes);

app.get('/health', (req, res) => res.json({ ok: true }));

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`listening on ${port}`));

Step 12: Seed Test Tenants and Start the Server

Create a small one-off script, seed.js, to create two competing tenants and a handful of users across different roles:

// seed.js
require('dotenv').config();
const bcrypt = require('bcryptjs');
const { pool } = require('./db');

async function seed() {
  const passwordHash = await bcrypt.hash('correct-horse-battery', 10);

  const { rows: tenantRows } = await pool.query(
    `INSERT INTO tenants (name) VALUES ('Acme Corp'), ('Globex Inc') RETURNING id, name`
  );
  const acme = tenantRows.find((t) => t.name === 'Acme Corp');
  const globex = tenantRows.find((t) => t.name === 'Globex Inc');

  const users = [
    { tenant: acme, email: '[email protected]', role: 'TenantAdmin' },
    { tenant: acme, email: '[email protected]', role: 'Member' },
    { tenant: acme, email: '[email protected]', role: 'Viewer' },
    { tenant: globex, email: '[email protected]', role: 'TenantAdmin' },
  ];

  for (const u of users) {
    await pool.query(
      'INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4)',
      [u.tenant.id, u.email, passwordHash, u.role]
    );
  }

  console.log(JSON.stringify({ acme, globex }, null, 2));
  await pool.end();
}

seed().catch((err) => { console.error(err); process.exit(1); });
node seed.js

Expected output: a JSON object with the generated acme and globex tenant IDs. Note both down; you will need them for the login calls in Step 13. Now start the server:

node server.js

Expected output: listening on 3000, with no errors above it. If you see the ERR_ERL_KEY_GEN_IPV6 error from Step 10 here, the rate limiter file still has the uncorrected keyGenerator. In a second terminal, confirm the process is actually accepting connections:

curl -s http://127.0.0.1:3000/health

Expected output: {"ok":true}.

Step 13: Verify Tenant Isolation, RBAC, Audit Logging, and Rate Limiting End to End

This is the step that actually proves the system works, rather than just compiles. Every request below and its result is a real transcript from testing this exact code.

13a. Log in as each test user (substitute your own tenant IDs from Step 12’s output):

curl -s -X POST http://127.0.0.1:3000/auth/login -H "Content-Type: application/json" \
  -d '{"tenantId":"","email":"[email protected]","password":"correct-horse-battery"}'

Expected output: a JSON object containing a token field. Repeat for [email protected] and for [email protected], saving each token. A login attempt with the wrong password returns 401, not a hint about which part was wrong.

13b. Tenant isolation. Have the Acme admin and the Globex admin each create one project, then have each list projects:

=== acme admin lists projects ===
{"projects":[{"id":"c2c59050-...","name":"Acme Rocket Launch", ...}]}
=== globex admin lists projects ===
{"projects":[{"id":"f865e7f9-...","name":"Globex Death Ray", ...}]}

Each tenant sees exactly one project: its own. Neither list contains the other tenant’s project, even though both are rows in the same shared projects table.

13c. Cross-tenant access by ID. Have the Globex admin request Acme’s project directly by its ID:

curl -s -w "\nHTTP_STATUS:%{http_code}\n" http://127.0.0.1:3000/projects/c2c59050-01d1-43f8-bd43-8d6104875217 \
  -H "Authorization: Bearer "

{"error":"Project not found"}
HTTP_STATUS:404

A real project ID belonging to a different tenant is indistinguishable from an ID that does not exist at all. That is getProjectById‘s dual WHERE tenant_id = $1 AND id = $2 filter from Step 8 doing its job.

13d. RBAC enforcement. Have the Acme Viewer (not the admin) attempt to create a project:

{"error":"Insufficient role for this action"}
HTTP_STATUS:403

The same Viewer token against GET /projects succeeds with 200, since Viewer is the minimum role that route requires. A request with no token at all gets 401 with {"error":"Missing bearer token"}, before RBAC or rate limiting ever run.

13e. Audit log correctness and immutability. Check the rows the two project creations above actually wrote:

PGPASSWORD=app_password psql -h 127.0.0.1 -U app_user -d saas_api -c \
  "SELECT action, resource_type, actor_user_id, tenant_id, created_at FROM audit_logs ORDER BY created_at;"

     action     | resource_type |            actor_user_id             |              tenant_id
----------------+---------------+---------------------------------------+--------------------------------------
 project.create | project       | a3c8954d-14bb-4e22-bd20-4d81f69c6de1  | 3d159c73-ced2-44f2-8444-17110c969fde
 project.create | project       | 3d0d60bd-afc2-44c4-8723-5505186cdbe8  | 4194941a-f9f0-4d93-ac3d-45304530421b

Two rows, correctly scoped to the tenant and user that created each project. Now try to tamper with one, using the exact same database role the running API connects as:

PGPASSWORD=app_password psql -h 127.0.0.1 -U app_user -d saas_api -c \
  "UPDATE audit_logs SET action = 'tampered' WHERE resource_type = 'project';"

ERROR:  permission denied for table audit_logs

The REVOKE from Step 3 holds under a real attempt, using the application’s own credentials, not just a superuser demonstration.

13f. Per-tenant rate limiting. Fire seven rapid GET /projects requests as the Acme admin. The limit is five requests per 60-second window, and logging in does not count against it, since /auth/login sits outside the /projects router and never reaches tenantRateLimiter. But by this point in the walkthrough, four earlier calls to /projects had already come from the Acme tenant, the admin’s project creation and list calls from 13b, plus the Viewer’s rejected creation attempt and successful list call from 13d, so four of the five requests in the window were already spent before this test’s first request:

request 1 -> HTTP 200
request 2 -> HTTP 429
request 3 -> HTTP 429
request 4 -> HTTP 429
request 5 -> HTTP 429
request 6 -> HTTP 429
request 7 -> HTTP 429

Immediately afterward, a Globex admin request succeeds with a fresh 200, confirming Globex has its own independent counter, not one shared globally. The blocked response includes standard rate-limit headers:

HTTP/1.1 429 Too Many Requests
RateLimit-Policy: 5;w=60
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 16
Retry-After: 16

Retry-After: 16 tells a well-behaved client exactly how many seconds to wait before trying again, rather than guessing or retrying immediately.

Common Mistakes and Gotchas

Reading tenant_id from anywhere other than the verified JWT

The instant a route trusts a tenantId from req.body, a query string, or a client-controlled header, tenant isolation is gone, because any caller can simply claim to be a different tenant. Every repository function in this tutorial takes tenantId as a parameter supplied by the route handler from req.user.tenantId, never from parsed request input.

Rate limiting before authentication

If the limiter runs first, it has no tenantId to key on yet and falls back to per-IP limiting, which is exactly the failure mode (one corporate proxy IP shared by many real users) that a per-tenant limiter exists to avoid. Authentication has to run first so the limiter always has a real tenant to key on.

The express-rate-limit v8+ IPv6 validation error

If your keyGenerator‘s fallback branch uses req.ip directly instead of ipKeyGenerator(req.ip), the server refuses to start with ERR_ERL_KEY_GEN_IPV6. This check was added in version 8.0.0; code written against an older express-rate-limit tutorial will hit this immediately on a fresh install.

ON DELETE CASCADE on an append-only audit table

If audit_logs.tenant_id cascaded from tenants, deleting a tenant would attempt to cascade-delete its audit rows, and that cascade delete would itself be blocked by the same REVOKE that makes the log tamper-resistant, since the connecting role no longer has DELETE on that table. Leaving the default (no cascade) forces tenant deletion to be a deliberate, separate step instead of an accidental side effect.

Bypassing the repository layer with a raw query

Row-level isolation only works if every single query goes through the tenant-safe repository layer. One raw query written directly in a route handler, without a tenant_id filter, reintroduces the exact bug from this tutorial’s opening story. Code review and, ideally, an automated test that asserts cross-tenant reads return nothing, are what catch this before it reaches production.

How to Confirm Everything Works End to End

  1. curl http://127.0.0.1:3000/health returns {"ok":true}.
  2. Logging in with a wrong password returns 401, and a correct login returns a JWT.
  3. Two different tenants each see only their own projects in GET /projects.
  4. Fetching another tenant’s real project ID directly returns 404, not the project’s data.
  5. A Viewer-role token gets 403 from POST /projects but 200 from GET /projects.
  6. Every project creation produces a matching row in audit_logs, correctly scoped to tenant and actor.
  7. An UPDATE or DELETE against audit_logs, run as the application’s own database role, fails with permission denied.
  8. Six or more requests from one tenant inside 60 seconds produce a 429 with Retry-After, while a different tenant is unaffected.

If all eight checks pass, you have a working multi-tenant API with verified isolation, enforced RBAC, a tamper-resistant audit trail, and per-tenant rate limiting, not just code that looks like it should have those properties.

Next Steps

  • Add an integration test suite that automates Step 13’s checks (especially the cross-tenant isolation and audit-immutability tests) and wire it into CI, so a future change cannot silently reintroduce the opening story’s bug.
  • Replace the plain tenantId field in /auth/login with tenant resolution from a subdomain or invite token, so a client never needs to know or supply a raw tenant ID at all.
  • Add refresh tokens and a shorter access-token expiry than this tutorial’s one hour, plus a revocation path for compromised tokens.
  • If your compliance requirements demand a complete audit trail with no gaps, revisit the fire-and-forget design in Step 9 and make a failed audit write block or retry the action it was supposed to record.
  • As a tenant count grows into the thousands with very different data-volume profiles per tenant, revisit whether row-level isolation is still the right choice, or whether your largest tenants warrant schema-per-tenant treatment.

Sources: freeCodeCamp: How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging (Zia Ullah); PostgreSQL documentation: REVOKE; PostgreSQL documentation: gen_random_uuid(); express-rate-limit documentation: ERR_ERL_KEY_GEN_IPV6; rate-limit-redis documentation; jwt.io: Introduction to JSON Web Tokens; node-postgres (pg) documentation. Featured image: photograph titled “Securing a shut down industrial site” by ImagePerson, CC BY-SA 4.0, via Wikimedia Commons.

Tags:

API SecurityNode.jsPostgreSQLRedisSaaS Security

Share

1789 engraving showing the hidden human operator inside the cabinet of The Turk, the chess-playing automaton, a public domain illustration by Joseph Racknitz
Previous Post

Substack’s Pangram Rollout Turns AI Detection Into a Reader Feature

AMD chair and CEO Lisa Su speaking at a podium with AMD and Oak Ridge National Laboratory signage in the background
Next Post

AMD Commits Up to $5 Billion to Anthropic in a 2-Gigawatt AI Chip Deal

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
28 Oct
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
Trending
October 28, 2026
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026