Express Multi-Tenant API with RLS
This is the simplest path to a multi-tenant API. Express handles HTTP, pg handles queries, and PostgreSQL Row-Level Security enforces tenant isolation at the database level. No ORM required.
What You Get
- Tenant resolution from headers via Stratum SDK middleware
- Every SQL query automatically scoped to the current tenant
- No manual
WHERE tenant_id = ?clauses - Config and permissions resolved per-tenant from the Stratum control plane
Project Setup
1. Initialize
mkdir my-api && cd my-apinpm init -ynpm install express pg @stratum-hq/lib @stratum-hq/db-adaptersnpm install -D typescript @types/express @types/pg tsx2. Start PostgreSQL
services: db: image: postgres:16-alpine environment: POSTGRES_USER: stratum POSTGRES_PASSWORD: stratum_dev POSTGRES_DB: stratum ports: - "5432:5432" volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sqldocker compose up -ddocker run -d --name stratum-db \ -e POSTGRES_USER=stratum \ -e POSTGRES_PASSWORD=stratum_dev \ -e POSTGRES_DB=stratum \ -p 5432:5432 postgres:16-alpine3. Create Tables with RLS
-- init.sqlCREATE EXTENSION IF NOT EXISTS "uuid-ossp";CREATE EXTENSION IF NOT EXISTS "ltree";
-- Application tableCREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL, product TEXT NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, total NUMERIC(10,2) NOT NULL, created_at TIMESTAMPTZ DEFAULT now());
-- Enable RLSALTER TABLE orders ENABLE ROW LEVEL SECURITY;ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- Tenant isolation policyCREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Performance indexCREATE INDEX idx_orders_tenant ON orders (tenant_id, created_at DESC);The Application
Tenant Middleware
The middleware reads the tenant ID from the x-tenant-id header and creates a tenant-scoped connection pool for the request:
import { Pool } from "pg";import { createTenantPool } from "@stratum-hq/db-adapters";import { Request, Response, NextFunction } from "express";
export function tenantMiddleware(pool: Pool) { return (req: Request, res: Response, next: NextFunction) => { const tenantId = req.headers["x-tenant-id"] as string; if (!tenantId) { res.status(400).json({ error: "Missing x-tenant-id header" }); return; }
// Create a tenant-scoped pool for this request req.tenantPool = createTenantPool(pool, () => tenantId); req.tenantId = tenantId; next(); };}createTenantPool returns a pool wrapper that automatically calls set_config('app.current_tenant_id', tenantId, true) inside a transaction before every query. RLS policies do the rest.
Express App
import express from "express";import { Pool } from "pg";import { Stratum } from "@stratum-hq/lib";import { tenantMiddleware } from "./middleware.js";
const app = express();app.use(express.json());
const pool = new Pool({ connectionString: process.env.DATABASE_URL || "postgres://stratum:stratum_dev@localhost:5432/stratum",});
const stratum = new Stratum({ pool, autoMigrate: true });
// Apply tenant middleware to all /api routesapp.use("/api", tenantMiddleware(pool));
// List orders (automatically filtered by RLS)app.get("/api/orders", async (req, res) => { const result = await req.tenantPool.query( "SELECT * FROM orders ORDER BY created_at DESC" ); res.json(result.rows);});
// Create an orderapp.post("/api/orders", async (req, res) => { const { product, quantity, total } = req.body; const result = await req.tenantPool.query( "INSERT INTO orders (tenant_id, product, quantity, total) VALUES ($1, $2, $3, $4) RETURNING *", [req.tenantId, product, quantity, total] ); res.status(201).json(result.rows[0]);});
// Get order by ID (RLS ensures it belongs to the current tenant)app.get("/api/orders/:id", async (req, res) => { const result = await req.tenantPool.query( "SELECT * FROM orders WHERE id = $1", [req.params.id] ); if (result.rows.length === 0) { res.status(404).json({ error: "Order not found" }); return; } res.json(result.rows[0]);});
// Delete an order (RLS prevents deleting another tenant's order)app.delete("/api/orders/:id", async (req, res) => { const result = await req.tenantPool.query( "DELETE FROM orders WHERE id = $1 RETURNING *", [req.params.id] ); if (result.rows.length === 0) { res.status(404).json({ error: "Order not found" }); return; } res.json({ deleted: result.rows[0] });});
// Health check (no tenant required)app.get("/health", (req, res) => { res.json({ status: "ok" });});
async function main() { await stratum.initialize(); app.listen(3000, () => console.log("Listening on :3000"));}
main();TypeScript Declarations
Add type declarations for the request properties:
import { TenantPool } from "@stratum-hq/db-adapters";
declare global { namespace Express { interface Request { tenantPool: TenantPool; tenantId: string; } }}Running the App
npx tsx src/index.tsTesting Isolation
Create two tenants and verify they cannot see each other’s data:
# Create tenants via Stratum control planeTENANT_A="aaaaaaaa-0000-0000-0000-000000000001"TENANT_B="bbbbbbbb-0000-0000-0000-000000000002"
# Insert orders for Tenant Acurl http://localhost:3000/api/orders -X POST \ -H "x-tenant-id: $TENANT_A" \ -H "Content-Type: application/json" \ -d '{"product": "Widget", "quantity": 5, "total": "49.95"}'
curl http://localhost:3000/api/orders -X POST \ -H "x-tenant-id: $TENANT_A" \ -H "Content-Type: application/json" \ -d '{"product": "Sprocket", "quantity": 10, "total": "99.90"}'
# Insert orders for Tenant Bcurl http://localhost:3000/api/orders -X POST \ -H "x-tenant-id: $TENANT_B" \ -H "Content-Type: application/json" \ -d '{"product": "Gadget", "quantity": 2, "total": "29.90"}'
# Tenant A sees 2 orders (Widget + Sprocket)curl http://localhost:3000/api/orders -H "x-tenant-id: $TENANT_A"
# Tenant B sees 1 order (Gadget)curl http://localhost:3000/api/orders -H "x-tenant-id: $TENANT_B"
# Tenant B cannot delete Tenant A's order (returns 404, not 403)ORDER_A_ID=$(curl -s http://localhost:3000/api/orders \ -H "x-tenant-id: $TENANT_A" | jq -r '.[0].id')
curl -X DELETE "http://localhost:3000/api/orders/$ORDER_A_ID" \ -H "x-tenant-id: $TENANT_B"# {"error": "Order not found"}The last test is the important one. Tenant B tries to delete Tenant A’s order by ID. RLS filters the DELETE query, so no row matches, and the API returns 404. The tenant never knows the order exists.
Adding More Tables
Use Stratum’s migration helpers to add RLS to new tables:
import { migrateTable } from "@stratum-hq/db-adapters";
const client = await pool.connect();try { await client.query("BEGIN"); await migrateTable(client, "invoices"); await migrateTable(client, "products"); await client.query("COMMIT");} finally { client.release();}Or use the CLI:
npx @stratum-hq/cli migrate invoices productsUsing an ORM Instead
If you prefer an ORM over raw SQL:
- Prisma: See Multi-Tenancy with Prisma and PostgreSQL RLS
- Drizzle: See Multi-Tenancy with Drizzle ORM
Both guides use the same RLS setup. Only the query layer changes.
Scaffold with Stratum CLI
Skip the manual setup entirely:
npx @stratum-hq/create my-apicd my-apinpx @stratum-hq/cli initnpx @stratum-hq/cli scaffold expressThis generates the middleware, route templates, and environment configuration. The scaffolded Express middleware uses the Stratum SDK client for tenant resolution with JWT support.
Generate a ready-to-run project
Skip the manual setup. The Stack Wizard configures your database, ORM, and framework, then gives you a single command to scaffold the whole project.
Next Steps
- Learn about isolation strategies to choose between RLS, schema-per-tenant, and database-per-tenant
- Build a Next.js multi-tenant app with subdomain routing
- Add config inheritance for per-tenant feature flags and limits
- Set up ABAC policies for fine-grained permission control