How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging — Opportunihub
Course Remote

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

Zia Ullah · Remote

At a glance

Type
Course
Organisation
Zia Ullah
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
20 Jul 2026

About this course

<p>A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created.</p> <p>I pulled up the query logs expecting something subtle. It was not. The list endpoint had no <code>tenant_id</code> filter at all. Every tenant in the database could read every other tenant's projects. The application never threw an error. It just returned whatever was there.</p> <p>Missing tenant filters don't throw errors. They return the wrong data without any complaint, and nothing in your logs will flag it. I've seen this run in production for weeks before a support ticket pointed anyone at the query logs.</p> <p>When it does surface, who finds it first matters a lot. A customer noticing it is bad. A compliance auditor noticing it during a SOC 2 review is a different kind of problem.</p> <p>Isolation built in from the start is a day of work. The time I spent helping a team retrofit it after a compliance review was considerably longer than that, and involved more customer emails than anyone wanted to write.</p> <p>The stack is Node.js with PostgreSQL. CRUD is the easy part. Tenant isolation, RBAC, and audit logging take more care, and where those checks run in the stack matters. I put all three in middleware, before any route handler fires. A handler that never calls the isolation logic directly can't accidentally skip it.</p> <h2 id="heading-prerequisites">Prerequisites</h2> <ul> <li><p>Node.js 18+</p> </li> <li><p>PostgreSQL 14+</p> </li> <li><p>Basic knowledge of Express.js and JWT</p> </li> </ul> <h2 id="heading-what-we-will-build">What We Will Build</h2> <p>A multi-tenant Express REST API that enforces:</p> <ol> <li><p><strong>Tenant isolation:</strong> every database query scopes to the <code>tenant_id</code> from the verified JWT. The client can't influence which tenant the query runs against.</p> </li> <li><p><strong>RBAC:</strong> four roles, each with a numeric level (SuperAdmin is highest, Viewer lowest). Middleware checks the level before the handler runs.</p> </li> <li><p><strong>Audit logging:</strong> any write or sensitive read appends a row to the audit table. The app can't modify those rows afterward. The database enforces this directly. If a bug in the app tries to UPDATE an audit row, the database refuses it. Application-level enforcement alone can't give you that guarantee.</p> </li> <li><p><strong>Per-tenant rate limiting:</strong> request counts in Redis, keyed to the tenant. I've seen IP-based limiting break an enterprise rollout when fifty users came through a single corporate proxy.</p> </li> <li><p><strong>Tenant isolation tests:</strong> a dedicated test file that proves cross-tenant data can't leak. Wire it into CI and it catches broken isolation before it ships.</p> </li> </ol> <h2 id="heading-table-of-contents">Table of Contents</h2> <ol> <li><p><a href="#heading-how-multi-tenancy-works">How Multi-Tenancy Works</a></p> </li> <li><p><a href="#heading-architecture-overview">Architecture Overview</a></p> </li> <li><p><a href="#heading-database-schema-design">Database Schema Design</a></p> </li> <li><p><a href="#heading-project-setup">Project Setup</a></p> </li> <li><p><a href="#heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</a></p> </li> <li><p><a href="#heading-auth-and-rbac-middleware">Auth and RBAC Middleware</a></p> </li> <li><p><a href="#heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</a></p> </li> <li><p><a href="#heading-audit-logging-service">Audit Logging Service</a></p> </li> <li><p><a href="#heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</a></p> </li> <li><p><a href="#heading-building-the-routes">Building the Routes</a></p> </li> <li><p><a href="#heading-testing-tenant-isolation">Testing Tenant Isolation</a></p> </li> <li><p><a href="#heading-troubleshooting">Troubleshooting</a></p> </li> <li><p><a href="#heading-wrapping-up">Wrapping Up</a></p> </li> </ol> <h2 id="heading-how-multi-tenancy-works">How Multi-Tenancy Works</h2> <p>This tutorial uses a <strong>shared database with row-level isolation</strong>: a <code>tenant_id</code> column on every table, a filter on every query. The database holds everyone's data together. The application decides what each tenant can see.</p> <p>Two other approaches exist: schema-per-tenant and database-per-tenant. I've talked to teams on schema-per-tenant who ended up spending more engineering time on migration tooling than on their actual product. Database-per-tenant gives stronger guarantees but a connection pool that balloons with every new customer signup.</p> <p>Neither scales cheaply. Row-level isolation scales further than most teams expect. The ones I know who moved off it did so years in, usually under specific regulatory pressure, not because the approach stopped working.</p> <p>The one thing in this design that can't be optional: <code>tenant_id</code> <strong>must always come from the verified JWT.</strong> Not from the request body, not from the URL. Users control what they put in both of those. They don't control what gets signed into a JWT on your server.</p> <h2 id="heading-architecture-overview">Architecture Overview</h2> <pre><code class="language-plaintext">HTTP Request │ ▼ ┌─────────────────────────────────────────┐ │ Express Middleware Stack │ │ │ │ 1. Rate Limiter (per tenant_id) │ │ 2. Auth Middleware (verify JWT) │ │ └─► Extracts: userId, tenantId, │ │ role, permissions │ │ 3. RBAC Middleware (check role) │ └──────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Route Handler │ │ │ │ 1. Call Repository (tenant-safe query) │ │ 2. Call Audit Service (fire &amp; forget) │ │ 3. Return response │ └──────────────┬──────────────────────────┘ │ ┌─────────┴──────────┐ ▼ ▼ ┌─────────┐ ┌────────────┐ │ Projects│ │ Audit Logs │ │ Table │ │ Table │ │(+tenant)│ │(append only│ └─────────┘ └────────────┘ </code></pre> <p>Rate limiting, auth, and RBAC all run before any handler sees the request. Writes pass through the audit service. The repository takes <code>tenantId</code> from <code>req.user</code> and the handler never touches tenant scoping directly, so there's no path around it.</p> <h2 id="heading-database-schema-design">Database Schema Design</h2> <pre><code class="language-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', -- 'free', 'pro', 'enterprise' 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, role VARCHAR(50) NOT NULL DEFAULT 'Member', -- 'SuperAdmin','TenantAdmin','Member','Viewer' created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_id, email) ); CREATE INDEX idx_users_tenant ON users(tenant_id); -- Projects table (example resource — replace with your domain entity) 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(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_projects_tenant ON projects(tenant_id); -- Audit log table (append-only — never UPDATE or DELETE rows here) CREATE TABLE audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, user_id UUID NOT NULL, user_email TEXT NOT NULL, user_role TEXT NOT NULL, -- role at time of action action TEXT NOT NULL, -- 'CREATE', 'UPDATE', 'DELETE', 'VIEW' resource TEXT NOT NULL, -- table name resource_id TEXT, old_values JSONB, new_values JSONB, ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id); CREATE INDEX idx_audit_created ON audit_logs(created_at DESC); -- Protect audit log at database level -- Use a DO block so this runs safely in Docker where app_user is the superuser DO $$ BEGIN IF current_user &lt;&gt; 'app_user' THEN REVOKE DELETE, UPDATE ON audit_logs FROM app_user; END IF; END $$; </code></pre> <p>The <code>REVOKE</code> matters. Application bugs happen. If something in your codebase accidentally tries to UPDATE an audit row, you want the database to refuse it outright, not silently comply.</p> <h2 id="heading-project-setup">Project Setup</h2> <pre><code class="language-bash">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 npm install --save-dev jest supertest </code></pre> <h3 id="heading-starting-postgresql-and-redis-with-docker">Starting PostgreSQL and Redis with Docker</h3> <p>Skip the local installs. One <code>docker-compose.yml</code> in the project root brings up both PostgreSQL and Redis:</p> <pre><code class="language-yaml">services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: saas_api POSTGRES_USER: app_user POSTGRES_PASSWORD: app_password ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql redis: image: redis:7-alpine ports: - "6379:6379" volumes: postgres_data: </code></pre> <p>That <code>schema.sql</code> mount runs your SQL automatically when the container first starts. No psql required.</p> <pre><code class="language-bash">docker compose up -d </code></pre> <p><code>.env</code> in the project root:</p> <pre><code class="language-plaintext">DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api REDIS_URL=redis://localhost:6379 JWT_SECRET=your_random_secret_here PORT=3000 NODE_ENV=development </code></pre> <p>Don't type a <code>JWT_SECRET</code> by hand. Run this to generate one:</p> <pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" </code></pre> <p>File structure:</p> <pre><code class="language-plaintext">nodejs-multitenant-saas-api/ ├── src/ │ ├── middleware/ │ │ ├── auth.js # JWT verification + tenant extraction │ │ ├── rbac.js # Role enforcement │ │ └── rateLimiter.js # Per-tenant rate limiting │ ├── services/ │ │ └── auditService.js # Append-only audit logger │ ├── repositories/ │ │ └── projectRepo.js # Tenant-safe DB queries │ ├── routes/ │ │ └── projects.js # Route handlers │ └── utils/ │ └── token.js # JWT token generation ├── db/ │ ├── index.js # PostgreSQL pool │ └── redis.js # Redis client ├── docker-compose.yml ├── app.js ├── server.js └── tests/ └── tenantIsolation.test.js </code></pre> <h3 id="heading-boilerplate-files">Boilerplate Files</h3> <p>There are four files the tutorial doesn't cover in detail, but the test file needs all of them to run:</p> <pre><code class="language-javascript">// db/index.js const { Pool } = require('pg'); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); pool.on('error', (err) =&gt; console.error('PostgreSQL error:', err.message)); module.exports = { pool }; </code></pre> <pre><code class="language-javascript">// db/redis.js const Redis = require('ioredis'); const redisClient = new Redis(process.env.REDIS_URL); redisClient.on('error', (err) =&gt; console.error('Redis error:', err.message)); module.exports = { redisClient }; </code></pre> <pre><code class="language-javascript">// app.js require('dotenv').config(); const express = require('express'); const projectsRouter = require('./src/routes/projects'); const app = express(); app.use(express.json()); app.use('/api/projects', projectsRouter); // Global error handler — must have 4 parameters to be recognised by Express app.use((err, req, res, next) =&gt; { console.error(err.stack); res.status(500).json({ error: 'Internal server error' }); }); module.exports = app; </code></pre> <pre><code class="language-javascript">// server.js const app = require('./app'); const PORT = process.env.PORT || 3000; app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`)); </code></pre> <p><code>bcryptjs</code> is included for a login endpoint with proper password hashing. That part isn't covered here, but the GitHub repo has a working <code>/api/auth/login</code> example.</p> <h2 id="heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</h2> <p>Both <code>tenantId</code> and <code>role</code> go into the JWT payload. Everything downstream reads from these two fields. Get them wrong, and nothing behaves correctly.</p> <pre><code class="language-javascript">// Example JWT payload { "userId": "usr_abc123", "tenantId": "ten_xyz789", "email": "alice@acme.com", "role": "TenantAdmin", "iat": 1720000000, "exp": 1720086400 } </code></pre> <p>The roles in order of privilege:</p> <ul> <li><p><strong>SuperAdmin:</strong> cross-tenant access for your internal team only</p> </li> <li><p><strong>TenantAdmin:</strong> full access within their tenant</p> </li> <li><p><strong>Member:</strong> read and write within their tenant</p> </li> <li><p><strong>Viewer:</strong> read-only within their tenant</p> </li> </ul> <p>Generate a token (used for testing and your auth endpoint):</p> <pre><code class="language-javascript">// src/utils/token.js const jwt = require('jsonwebtoken'); function generateToken({ userId, tenantId, email, role }) { return jwt.sign( { userId, tenantId, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' } ); } module.exports = { generateToken }; </code></pre> <h2 id="heading-auth-and-rbac-middleware">Auth and RBAC Middleware</h2> <p>The auth middleware does two things: verifies the JWT signature and extracts the tenant context into <code>req.user</code>.</p> <p>That second part is what the entire system depends on. Every query downstream reads <code>req.user.tenantId</code>. The client has no say in what that value is. They send a token the server signed, and the server reads back what it put in.</p> <pre><code class="language-javascript">// src/middleware/auth.js const jwt = require('jsonwebtoken'); function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing or malformed Authorization header' }); } const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, process.env.JWT_SECRET); // tenantId always comes from the verified token — never req.body or req.params req.user = { userId: decoded.userId, tenantId: decoded.tenantId, email: decoded.email, role: decoded.role, }; next(); } catch (err) { return res.status(401).json({ error: 'Invalid or expired token' }); } } module.exports = { authMiddleware }; </code></pre> <p>The RBAC middleware is separate from auth by design. Auth runs on every route. Role enforcement only applies where a minimum role is required. You pass the allowed roles to <code>requireRole()</code> and it compares the user's level against the hierarchy. A Viewer trying to delete something hits the 403 before the handler ever runs.</p> <pre><code class="language-javascript">// src/middleware/rbac.js const ROLE_HIERARCHY = { SuperAdmin: 4, TenantAdmin: 3, Member: 2, Viewer: 1, }; // requireRole('TenantAdmin') — user must be TenantAdmin or higher function requireRole(...roles) { return (req, res, next) =&gt; { const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0; const requiredLevel = Math.min(...roles.map(r =&gt; ROLE_HIERARCHY[r] ?? 999)); if (userLevel &lt; requiredLevel) { return res.status(403).json({ error: 'Insufficient permissions', required: roles, current: req.user?.role, }); } next(); }; } module.exports = { requireRole }; </code></pre> <h2 id="heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</h2> <p>Isolation lives here. Every function takes <code>tenantId</code> as a required argument, pulled from <code>req.user</code> by the handler. There's no way to call these without providing a tenant scope. I've watched teams try to handle this with a URL parameter instead (<code>GET /api/projects?tenantId=xyz</code>) and call it isolated. It is not. Any client sends whatever it wants in a query string.</p> <pre><code class="language-javascript">// src/repositories/projectRepo.js const { pool } = require('../../db'); // List all projects for a tenant — tenantId is ALWAYS from the JWT async function listProjects(tenantId) { const result = await pool.query( `SELECT id, name, description, created_by, created_at FROM projects WHERE tenant_id = $1 ORDER BY created_at DESC`, [tenantId] ); return result.rows; } // Get a single project — returns null if it belongs to a different tenant // NOTE: Returns 404 (not 403) intentionally — don't reveal the resource exists async function getProject(id, tenantId) { const result = await pool.query( `SELECT id, name, description, created_by, created_at FROM projects WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); return result.rows[0] || null; } async function createProject({ tenantId, name, description, createdBy }) { const result = await pool.query( `INSERT INTO projects (tenant_id, name, description, created_by) VALUES ($1, $2, $3, $4) RETURNING *`, [tenantId, name, description, createdBy] ); return result.rows[0]; } async function updateProject(id, tenantId, updates) { const result = await pool.query( `UPDATE projects SET name = COALESCE($3, name), description = COALESCE($4, description), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 RETURNING *`, [id, tenantId, updates.name, updates.description] ); return result.rows[0] || null; } async function deleteProject(id, tenantId) { const result = await pool.query( `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`, [id, tenantId] ); return result.rows[0] || null; } module.exports = { listProjects, getProject, createProject, updateProject, deleteProject }; </code></pre> <p>Notice what <code>getProject</code> does when Tenant A tries to fetch a Tenant B resource. The query runs with Tenant A's <code>tenantId</code>. The condition <code>id = $1 AND tenant_id = $2</code> matches nothing, <code>null</code> comes back, and the handler sends a <code>404</code>. Not a <code>403</code>. A 403 tells the caller the resource exists, but they can't access it, which is information they shouldn't have.</p> <h2 id="heading-audit-logging-service">Audit Logging Service</h2> <pre><code class="language-javascript">// src/services/auditService.js const { pool } = require('../../db'); async function log({ tenantId, userId, userEmail, userRole, // role at time of action — roles change, log should not action, // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW' resource, // table name resourceId = null, oldValues = null, newValues = null, ipAddress = null, userAgent = null, }) { const query = ` INSERT INTO audit_logs (tenant_id, user_id, user_email, user_role, action, resource, resource_id, old_values, new_values, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; const values = [ tenantId, userId, userEmail, userRole, action, resource, resourceId, oldValues ? JSON.stringify(oldValues) : null, newValues ? JSON.stringify(newValues) : null, ipAddress, userAgent, ]; // Fire-and-forget — audit logging must never block or fail a user request pool.query(query, values).catch((err) =&gt; { console.error('[AuditService] Failed to write log:', err.message); }); } module.exports = { log }; </code></pre> <p>Capturing <code>userRole</code> at write time matters more than it looks. User roles change after the fact: someone gets demoted, a permission is revoked. If the log only records the user ID, you lose the context of what privilege they held when the action happened. Store the role at the time of the action, and you always know.</p> <h2 id="heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</h2> <p>IP-based rate limiting breaks down in SaaS. A corporate customer might route hundreds of users through a single NAT gateway, sharing one IP address. One heavy tenant throttles everyone else on that address.</p> <p>I've watched teams discover this the hard way when an enterprise customer suddenly floods the API, and their other tenants start getting 429s with no explanation. Scope limits to <code>tenant_id</code> instead.</p> <pre><code class="language-javascript">// src/middleware/rateLimiter.js const rateLimit = require('express-rate-limit'); const { RedisStore } = require('rate-limit-redis'); const { redisClient } = require('../../db/redis'); // Rate limits by plan — extend as needed const PLAN_LIMITS = { free: { max: 100, windowMs: 15 * 60 * 1000 }, // 100 req / 15 min pro: { max: 500, windowMs: 15 * 60 * 1000 }, // 500 req / 15 min enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 2000 req / 15 min }; function createTenantRateLimiter(plan = 'free') { const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free; return rateLimit({ windowMs: limits.windowMs, max: limits.max, // Key = tenant_id from verified JWT — NOT the IP address keyGenerator: (req) =&gt; `tenant:${req.user?.tenantId || req.ip}`, store: new RedisStore({ sendCommand: (...args) =&gt; redisClient.call(...args), }), handler: (req, res) =&gt; { res.status(429).json({ error: 'Too many requests', retryAfter: Math.ceil(limits.windowMs / 1000), }); }, }); } // Default limiter for all API routes const defaultLimiter = createTenantRateLimiter('free'); module.exports = { defaultLimiter, createTenantRateLimiter }; </code></pre> <h2 id="heading-building-the-routes">Building the Routes</h2> <p>This is where everything connects. Auth and rate limiting apply to the whole router. Role checks go on individual routes. The audit log fires after every write. <code>tenantId</code> never comes from the request body or URL. <code>req.user.tenantId</code> is the only source, set by the auth middleware from the verified token, so there's no path around it.</p> <p>One practical detail for Express 4: it doesn't catch async errors automatically. Every handler wraps its logic in try/catch and passes failures to <code>next(err)</code>. Skip that and an unhandled promise rejection returns a blank 500 with no log entry and no audit trail. The comment at the top of the router is a reminder that the pattern is intentional.</p> <pre><code class="language-javascript">// src/routes/projects.js const express = require('express'); const { authMiddleware } = require('../middleware/auth'); const { requireRole } = require('../middleware/rbac'); const { defaultLimiter } = require('../middleware/rateLimiter'); const audit = require('../services/auditService'); const repo = require('../repositories/projectRepo'); const router = express.Router(); // All routes require authentication router.use(authMiddleware); router.use(defaultLimiter); // Express 4 does not catch async errors automatically. // Every handler must wrap await calls in try/catch and pass errors to next(). // Without this, an unhandled promise rejection silently returns 500 // with no useful message and no audit log entry. // GET /api/projects — list all (Viewer and above) router.get('/', async (req, res, next) =&gt; { try { const projects = await repo.listProjects(req.user.tenantId); audit.log({ tenantId: req.user.tenantId, userId: req.user.userId, userEmail: req.user.email, userRole: req.user.role, action: 'VIEW', resource: 'projects', ipAddress: req.ip, userAgent: req.headers['user-agent'], }); res.json(projects); } catch (err) { next(err); } }); // GET /api/projects/:id — single project (Viewer and above) router.get('/:id', async (req, res, next) =&gt; { try { const project = await repo.getProject(req.params.id, req.user.tenantId); if (!project) return res.status(404).json({ error: 'Not found' }); res.json(project); } catch (err) { next(err); } }); // POST /api/projects — create (Member and above) router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; { try { const { name, description } = req.body; if (!name) return res.status(400).json({ error: 'name is required' }); const project = await repo.createProject({ tenantId: req.user.tenantId, name, description, createdBy: req.user.userId, }); audit.log({ tenantId: req.user.tenantId, userId: req.user.userId, userEmail: req.user.email, userRole: req.user.role, action: 'CREATE', resource: 'projects', resourceId: project.id, newValues: project, ipAddress: req.ip, userAgent: req.headers['user-agent'], }); res.status(201).json(project); } catch (err) { next(err); } }); // PUT /api/projects/:id — update (Member and above) router.put('/:id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; { try { const oldProject = await repo.getProject(req.params.id, req.user.tenantId); if (!oldProject) return res.status(404).json({ error: 'Not found' }); const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body); audit.log({ tenantId: req.user.tenantId, userId: req.user.userId, userEmail: req.user.email, userRole: req.user.role, action: 'UPDATE', resource: 'projects', resourceId: req.params.id, oldValues: oldProject, newValues: updated, ipAddress: req.ip, userAgent: req.headers['user-agent'], }); res.json(updated); } catch (err) { next(err); } }); // DELETE /api/projects/:id — TenantAdmin and above only router.delete('/:id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; { try { const project = await repo.getProject(req.params.id, req.user.tenantId); if (!project) return res.status(404).json({ error: 'Not found' }); await repo.deleteProject(req.params.id, req.user.tenantId); audit.log({ tenantId: req.user.tenantId, userId: req.user.userId, userEmail: req.user.email, userRole: req.user.role, action: 'DELETE', resource: 'projects', resourceId: req.params.id, oldValues: project, ipAddress: req.ip, userAgent: req.headers['user-agent'], }); res.json({ deleted: true }); } catch (err) { next(err); } }); module.exports = router; </code></pre> <h2 id="heading-testing-tenant-isolation">Testing Tenant Isolation</h2> <p>Skip the isolation tests and you're flying blind. The application keeps running, nothing throws an error, but two customers are reading each other's data.</p> <p>I've watched this sit undetected in production for months because nothing actually broke. The wrong data just showed up quietly. Automated tests on every pull request are the only reliable way to catch it early.</p> <pre><code class="language-javascript">// tests/tenantIsolation.test.js require('dotenv').config(); // must be first — loads DATABASE_URL and REDIS_URL const request = require('supertest'); const app = require('../app'); const { generateToken } = require('../src/utils/token'); const { pool } = require('../db'); const { redisClient } = require('../db/redis'); // Test fixture: two isolated tenants, one project in Tenant B async function seedTestData() { // Clean up from any previous run to avoid unique-constraint failures await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`); await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`); const tenantA = (await pool.query( `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id` )).rows[0].id; const tenantB = (await pool.query( `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id` )).rows[0].id; const userA = (await pool.query( `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`, [tenantA] )).rows[0].id; // userB owns the project in Tenant B — satisfies the created_by FK constraint const userB = (await pool.query( `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`, [tenantB] )).rows[0].id; const projectB = (await pool.query( `INSERT INTO projects (tenant_id, name, created_by) VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`, [tenantB, userB] )).rows[0].id; return { tenantA, tenantB, userA, projectB }; } describe('Tenant Isolation', () =&gt; { let data; beforeAll(async () =&gt; { data = await seedTestData(); }); afterAll(async () =&gt; { await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`); await pool.end(); await redisClient.quit(); // close Redis connection so Jest exits cleanly }); test('Tenant A user cannot read Tenant B project', async () =&gt; { const token = generateToken({ userId: data.userA, tenantId: data.tenantA, // ← Tenant A token email: 'usera@a.com', role: 'Member', }); const res = await request(app) .get(`/api/projects/${data.projectB}`) // ← Tenant B's project ID .set('Authorization', `Bearer ${token}`); // Must be 404, not 200 or 403 expect(res.status).toBe(404); }); test('Tenant A user cannot list Tenant B projects', async () =&gt; { const token = generateToken({ userId: data.userA, tenantId: data.tenantA, email: 'usera@a.com', role: 'TenantAdmin', }); const res = await request(app) .get('/api/projects') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); // Response must contain zero Tenant B projects const names = res.body.map(p =&gt; p.name); expect(names).not.toContain('TEST-Secret Project'); }); test('Viewer cannot delete a project', async () =&gt; { const token = generateToken({ userId: data.userA, tenantId: data.tenantA, email: 'usera@a.com', role: 'Viewer', // ← Viewer role }); const res = await request(app) .delete(`/api/projects/${data.projectB}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); }); }); </code></pre> <p>Run the tests:</p> <pre><code class="language-bash">npm test </code></pre> <p>Three tests, three boundaries confirmed. Wire these into CI so they run on every pull request. A future refactor that quietly drops the <code>tenant_id</code> filter will get caught before it ships.</p> <h2 id="heading-troubleshooting">Troubleshooting</h2> <h3 id="heading-tenant-a-can-see-tenant-bs-data">Tenant A can see Tenant B's data</h3> <p>One query is missing the <code>AND tenant_id = $N</code> clause. Search every repository file for <code>SELECT</code> statements and check each one. It's almost always this.</p> <h3 id="heading-403-forbidden-on-a-route-that-should-be-accessible"><code>403 Forbidden</code> on a route that should be accessible</h3> <p>The role string in the JWT doesn't match what <code>requireRole()</code> is checking. Check the exact string in the token payload. <code>'member'</code> and <code>'Member'</code> aren't the same thing. Paste your token into jwt.io and look at the role field directly.</p> <h3 id="heading-rate-limiter-isnt-working">Rate limiter isn't working</h3> <p>Redis is probably not connected. Log <code>redisClient.status</code> before the server starts. If it's not <code>ready</code>, the limiter has fallen back to in-memory, which means restarts reset all counters and tenant-scoped limiting stops working.</p> <h3 id="heading-audit-log-table-growing-very-large">Audit log table growing very large</h3> <p>Expected behaviour. Audit tables grow, that's the point. Once it gets large, ship rows older than a year to S3 or Azure Blob and keep querying against a smaller hot table. Most compliance requirements want at least 12 months of accessible logs anyway. Just don't DELETE from the table itself.</p> <h3 id="heading-jwtverify-throws-jsonwebtokenerror-invalid-signature"><code>jwt.verify</code> throws <code>JsonWebTokenError: invalid signature</code></h3> <p>The secret that signed the token doesn't match <code>JWT_SECRET</code> in the environment where you're verifying it. This comes up most when switching between environments or when a second service has a different value in its <code>.env</code>. Every service that calls <code>jwt.verify</code> needs the exact same secret. Copy it across, don't retype it.</p> <h2 id="heading-wrapping-up">Wrapping Up</h2> <p>The system you've built: row-level isolation in the repository, role checks before the handler runs, an audit table the app can't touch, and rate limits per tenant. That's the whole thing.</p> <p>The tests are what I see dropped most often. Teams build the isolation, ship it, and never write something that actually proves cross-tenant data can't leak. Then a query gets refactored six months later and the <code>tenant_id</code> filter quietly disappears. CI catches it. Manual code review rarely does.</p> <p>Schema-per-tenant comes up eventually if your product grows large enough. But not at the start. Row-level isolation handles more scale than most teams will ever hit, and it costs a fraction of the operational overhead.</p> <p>The full working code is available on GitHub: <a href="https://github.com/ziaongit/nodejs-multitenant-saas-api">nodejs-multitenant-saas-api</a></p>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Zia Ullah’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Zia Ullah’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.