Build Multi-Tenant SaaS Authentication dengan PostgreSQL RLS & JWT di Node.js
Poin Kunci Artikel Ini:
- Pernah dapat notif jam 3 pagi: data client A kebaca sama client B?
- Ini mimpi buruk setiap developer SaaS.
- Yang bahaya, bug-nya sering bukan di sistem login, tapi di tenant isolation yang bocor.
Pernah dapat notif jam 3 pagi: data client A kebaca sama client B? Ini mimpi buruk setiap developer SaaS. Yang bahaya, bug-nya sering bukan di sistem login, tapi di tenant isolation yang bocor.
Gue pernah ngalamin ini di project kantor. Bug kecil di WHERE clause bikin data leak antar tenant. Sejak kejadian itu, gue selalu pakai PostgreSQL Row Level Security (RLS) sebagai safety net. Di artikel ini, kita bakal build multi-tenant authentication yang beneran aman, dari PostgreSQL RLS sampai JWT management di Node.js.
Problem Multi-Tenant yang Sering Kelewat
Inti masalahnya simpel: satu aplikasi melayani banyak tenant (customer/organisasi), tapi data mereka harus benar-benar terpisah. Kalau ada bug di kode, bisa fatal.
Ada tiga pendekatan isolasi data:
- Database per tenant: Paling aman, tapi mahal dan ribet maintain ratusan database
- Schema per tenant: Lumayan balance, tapi tetep ada overhead di connection pooling
- Shared schema + tenant_id: Paling efisien, tapi risiko paling tinggi kalau salah implementasi
PostgreSQL RLS adalah solusi untuk pendekatan ketiga. RLS memaksa filtering di level database, bukan cuma di application logic. Jadi meskipun kode lupa WHERE tenant_id, database tetep enforce isolation.
Mana yang Cocok Buat Kamu?
Kalau tenant masih sedikit (< 50) dan butuh isolasi maksimal, schema-per-tenant oke. Tapi kalau target ratusan atau ribuan tenant dengan usage bervariasi, shared schema + RLS jauh lebih scalable. Resource pooling jadi efisien dan migration cuma sekali jalan.
Implementasi dari Nol
Kita build dari scratch. Stack: Node.js, Express, PostgreSQL 12+, dan jsonwebtoken.
1. Setup Database Schema dengan RLS
Bikin schema dasar dengan tenant_id di setiap tabel:
-- Tabel tenants
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabel users dengan tenant_id
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, email)
);
-- Tabel data aplikasi (contoh: projects)
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Sekarang aktifkan RLS dan buat policies:
-- Enable RLS pada tabel users
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Policy: user hanya bisa akses data tenant-nya sendiri
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Sama untuk tabel projects
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
Magic-nya ada di current_setting('app.current_tenant_id'). Kita set variable ini per-session sesuai JWT token yang login.
2. Design JWT Payload dengan Tenant Context
JWT payload harus include tenant_id dan user_id:
// utils/jwt.js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;
function generateToken(user) {
return jwt.sign(
{
userId: user.id,
tenantId: user.tenant_id,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn: '24h' }
);
}
function verifyToken(token) {
return jwt.verify(token, JWT_SECRET);
}
module.exports = { generateToken, verifyToken };
3. Middleware Autentikasi Express.js
Middleware ini extract JWT, set tenant context ke PostgreSQL session, dan attach ke request:
// middleware/auth.js
const { verifyToken } = require('../utils/jwt');
const db = require('../db/pool'); // PostgreSQL connection pool
async function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
try {
const decoded = verifyToken(token);
// Set tenant context untuk session ini
await db.query(
"SET LOCAL app.current_tenant_id = $1",
[decoded.tenantId]
);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid token' });
}
}
module.exports = { authenticateToken };
Perhatikan SET LOCAL. Ini set variable hanya untuk transaction/query scope saat itu, bukan global. Aman untuk connection pooling.
4. Query Pattern yang Aman
Sekarang query otomatis di-filter. Contoh endpoint get projects:
// routes/projects.js
const express = require('express');
const router = express.Router();
const db = require('../db/pool');
const { authenticateToken } = require('../middleware/auth');
router.get('/projects', authenticateToken, async (req, res) => {
try {
// Tidak perlu WHERE tenant_id! RLS handle otomatis
const result = await db.query(
'SELECT * FROM projects ORDER BY created_at DESC'
);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/projects', authenticateToken, async (req, res) => {
const { name, description } = req.body;
try {
// tenant_id dari req.user (JWT)
const result = await db.query(
'INSERT INTO projects (tenant_id, name, description) VALUES ($1, $2, $3) RETURNING *',
[req.user.tenantId, name, description]
);
res.status(201).json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
5. Testing Isolasi Data
Bikin test sederhana untuk memastikan RLS bekerja:
// test/tenant-isolation.test.js
const request = require('supertest');
const app = require('../app');
describe('Tenant Isolation', () => {
let tenant1Token, tenant2Token;
beforeAll(async () => {
// Login sebagai user dari tenant 1
const res1 = await request(app)
.post('/auth/login')
.send({ email: 'user1@tenant1.com', password: 'password' });
tenant1Token = res1.body.token;
// Login sebagai user dari tenant 2
const res2 = await request(app)
.post('/auth/login')
.send({ email: 'user2@tenant2.com', password: 'password' });
tenant2Token = res2.body.token;
});
test('Tenant 1 tidak bisa akses data Tenant 2', async () => {
// Buat project di tenant 2
const createRes = await request(app)
.post('/projects')
.set('Authorization', `Bearer ${tenant2Token}`)
.send({ name: 'Secret Project' });
const projectId = createRes.body.id;
// Coba akses dengan token tenant 1
const getRes = await request(app)
.get(`/projects/${projectId}`)
.set('Authorization', `Bearer ${tenant1Token}`);
expect(getRes.status).toBe(404); // Tidak ketemu karena di-filter RLS
});
});
Best Practice Production
Onboarding Tenant Baru
Saat tenant baru signup, buat workflow atomic:
async function onboardTenant(tenantData, adminUser) {
const client = await db.connect();
try {
await client.query('BEGIN');
// 1. Buat tenant
const tenantResult = await client.query(
'INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING *',
[tenantData.name, tenantData.slug]
);
const tenant = tenantResult.rows[0];
// 2. Buat admin user
const userResult = await client.query(
'INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING *',
[tenant.id, adminUser.email, adminUser.passwordHash, 'admin']
);
// 3. Setup default data (optional)
await client.query(
'INSERT INTO projects (tenant_id, name) VALUES ($1, $2)',
[tenant.id, 'Default Project']
);
await client.query('COMMIT');
return { tenant, user: userResult.rows[0] };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Indexing Strategy
Index tenant_id di semua tabel multi-tenant. Ini critical untuk performa:
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);
-- Composite index untuk query umum
CREATE INDEX idx_projects_tenant_created ON projects(tenant_id, created_at DESC);
Monitoring Cross-Tenant Access
Log setiap kali RLS policy block access. Ini indikator bug atau serangan:
// middleware/rls-monitor.js
const logger = require('../utils/logger');
function monitorRLS(req, res, next) {
const originalQuery = req.db.query;
req.db.query = async function(...args) {
try {
const result = await originalQuery.apply(this, args);
// Kalau query return 0 rows tapi expected ada data, log
if (result.rows.length === 0 && req.method !== 'GET') {
logger.warn('Possible RLS block', {
userId: req.user?.userId,
tenantId: req.user?.tenantId,
query: args[0],
path: req.path
});
}
return result;
} catch (error) {
throw error;
}
};
next();
}
Rate Limiting Per-Tenant
Pakai Redis untuk track rate limit per tenant, bukan per IP:
const redis = require('redis');
const client = redis.createClient();
async function tenantRateLimit(req, res, next) {
const tenantId = req.user.tenantId;
const key = `rate_limit:${tenantId}:${Date.now() / 60000 | 0}`;
const count = await client.incr(key);
await client.expire(key, 60);
if (count > 1000) { // 1000 requests per menit per tenant
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
}
Migration Strategy
Gunakan migration tools yang tenant-aware. Contoh dengan node-pg-migrate:
// migrations/1699999999999_add_status_to_projects.js
exports.up = (pgm) => {
pgm.addColumn('projects', {
status: {
type: 'TEXT',
default: 'active'
}
});
// Index dengan tenant_id
pgm.createIndex('projects', ['tenant_id', 'status']);
};
exports.down = (pgm) => {
pgm.dropColumn('projects', 'status');
};
Kapan Pakai Pendekatan Ini?
PostgreSQL RLS cocok kalau kamu build SaaS B2B dengan banyak tenant tapi usage per tenant tidak terlalu tinggi. Enforcement di level database jadi safety net terakhir, performa bagus dengan indexing proper, dan development jadi lebih tenang karena ada guard rail.
Trade-off utama: complexity di setup awal dan debugging yang lebih tricky karena filtering implisit. Tapi untuk mayoritas SaaS dengan ratusan tenant, pendekatan ini sweet spot antara keamanan dan efisiensi resource.
Kalau ada tenant yang butuh dedicated resources atau compliance strict, consider hybrid: RLS untuk tenant kecil-menengah, dedicated database untuk enterprise.
Langkah selanjutnya: implement di staging environment, load test dengan realistic tenant distribution, dan monitor query performance pakai pg_stat_statements. Good luck!




๐ฌ Komentar (0)
Tulis Komentar