Skip to content
All posts
·CodeLovers

Multi-tenancy that doesn't leak — org_id scoping and Postgres RLS

How CodeLovers implemented bulletproof multi-tenancy for AgroGina using PostgreSQL Row-Level Security, preventing cross-tenant data access even when application code has bugs.

Multi-tenancyPostgreSQLSecuritySaaS

Multi-tenant SaaS has one non-negotiable rule: tenant A must never see tenant B’s data. Application-level filtering (WHERE org_id = ?) works until it doesn’t — one forgotten clause, one N+1 query optimization gone wrong, and you’ve got a data breach.

The AgroGina requirement

We built AgroGina, a precision agriculture ERP serving Moroccan cooperatives. Each cooperative (tenant) has:

  • Farm parcels with geospatial boundaries
  • Crop planting schedules and harvest records
  • Teams (agronomists, field agents, admins)
  • Satellite-derived NDVI time-series data
  • Inventory (fertilizer, equipment) and sales records

Zero cross-tenant leakage was the primary architectural constraint. Agricultural cooperatives compete; exposing one coop’s farm data to another would destroy trust.

Why application-level filtering isn’t enough

Early prototypes used NestJS query filters:

async findFarmsByOrg(orgId: string) {
  return this.farmRepository.find({ where: { orgId } });
}

This breaks when:

  • A developer forgets .where({ orgId }) in a new query
  • Raw SQL queries bypass the ORM’s filters
  • Eager-loaded relationships pull in data without scoping
  • Performance optimizations (e.g., denormalized views) skip application logic

You cannot audit every query. The attack surface is every line of code that touches the database.

Postgres Row-Level Security to the rescue

Row-Level Security (RLS) moves tenant isolation into the database. Every table gets a policy enforced at the Postgres kernel level:

-- Enable RLS on the farms table
ALTER TABLE farms ENABLE ROW LEVEL SECURITY;

-- Create policy: users only see farms for their org
CREATE POLICY tenant_isolation ON farms
  USING (org_id = current_setting('app.current_org_id')::uuid);

Now, even raw SQL queries respect tenant boundaries:

-- This query respects RLS automatically
SELECT * FROM farms WHERE crop_type = 'wheat';
-- Postgres internally adds: AND org_id = current_setting('app.current_org_id')::uuid

Setting the session variable per request

NestJS sets app.current_org_id for every HTTP request via a global interceptor:

@Injectable()
export class TenantInterceptor implements NestInterceptor {
  async intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const orgId = request.user.orgId; // From JWT claims

    await this.dataSource.query(
      `SET LOCAL app.current_org_id = $1`,
      [orgId]
    );

    return next.handle();
  }
}

SET LOCAL scopes the variable to the current transaction. After the request completes, Postgres automatically clears it.

Handling multi-tenant foreign keys

Foreign keys across tenants require composite keys:

CREATE TABLE harvests (
  id UUID PRIMARY KEY,
  org_id UUID NOT NULL,
  farm_id UUID NOT NULL,
  harvest_date DATE NOT NULL,
  FOREIGN KEY (org_id, farm_id) REFERENCES farms(org_id, id)
);

This prevents a harvest from referencing a farm in another tenant, even if the attacker knows the farm’s UUID.

Testing tenant isolation

We built a penetration testing suite that attempts cross-tenant access:

it('blocks cross-tenant farm access via direct query', async () => {
  // User A creates a farm
  const farm = await createFarm(tenantA, 'Farm Alpha');

  // User B attempts raw SQL access
  const result = await queryAsUser(
    tenantB,
    `SELECT * FROM farms WHERE id = $1`,
    [farm.id]
  );

  expect(result.rows).toHaveLength(0); // RLS blocked it
});

RLS passed every test. Raw SQL, eager joins, forgot-filters — all blocked.

Performance considerations

Does RLS slow queries down? In practice, no. Postgres query planner treats RLS policies like any other WHERE clause. With proper indexes on org_id, the overhead is negligible.

Monitoring: We log queries slower than 500ms. Adding org_id indexes to three tables eliminated 90% of slow queries.

When RLS isn’t the right choice

RLS shines for strict multi-tenancy (B2B SaaS with strong isolation). It’s overkill for:

  • Single-tenant apps (obviously)
  • B2C apps where users share most data (social networks, marketplaces)
  • Apps with complex cross-tenant workflows (rare, but exists)

For AgroGina, RLS was non-negotiable. We sleep better knowing tenant isolation lives in the database, not in 10,000 lines of application code.


Building multi-tenant SaaS with strict isolation? Let’s design your data model.

Build with CodeLovers

Have a product challenge? We will assemble strategy, design, and engineering around your outcomes.

Start a project