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...
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_idcolumn on every tenant-owned table and aWHERE 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.
curlfor 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
curl http://127.0.0.1:3000/healthreturns{"ok":true}.- Logging in with a wrong password returns
401, and a correct login returns a JWT. - Two different tenants each see only their own projects in
GET /projects. - Fetching another tenant’s real project ID directly returns
404, not the project’s data. - A Viewer-role token gets
403fromPOST /projectsbut200fromGET /projects. - Every project creation produces a matching row in
audit_logs, correctly scoped to tenant and actor. - An
UPDATEorDELETEagainstaudit_logs, run as the application’s own database role, fails withpermission denied. - Six or more requests from one tenant inside 60 seconds produce a
429withRetry-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
tenantIdfield in/auth/loginwith 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.








No Comment! Be the first one.