All posts
Backend·May 2026·10 min

PostgreSQL RLS + AsyncLocalStorage: Defense-in-Depth for SaaS

Tenant context belongs in more than one place. Here's the pattern I use to propagate it through NestJS request scope and into PostgreSQL Row-Level Security policies — so even a missed WHERE clause can't leak data.

PostgreSQL RLS + AsyncLocalStorage: Defense-in-Depth for SaaS

Application-layer filtering is a convenience. RLS is a guarantee. The trick is propagating the tenant context from the HTTP request all the way down to the database session — without passing it as a parameter to every single query.

The pattern: AsyncLocalStorage + RLS

Node's `AsyncLocalStorage` lets you set a tenant context once per request and have it automatically available anywhere in the async call chain — including deep inside ORM methods that don't know about tenants.

import { AsyncLocalStorage } from 'async_hooks';

const tenantStorage = new AsyncLocalStorage<string>();

// NestJS interceptor — runs once per request
@Injectable()
export class TenantInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    const req = context.switchToHttp().getRequest();
    const tenantId = req.user.tenant_id;

    return tenantStorage.run(tenantId, () => next.handle());
  }
}

// Anywhere in the codebase — no parameters needed
function getCurrentTenant(): string {
  return tenantStorage.getStore() ?? throw new Error('No tenant context');
}

The RLS policy

On the database side, every tenant-scoped table gets an RLS policy:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON documents
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- The app connects as a non-superuser role so RLS applies
ALTER ROLE tenantkit_app NOBYPASSRLS;

Bridging the two

The bridge is a TypeORM listener that runs at the start of every transaction:

@EventSubscriber()
export class TenantSubscriber implements EntitySubscriberInterface {
  beforeTransactionStart(event: TransactionStartEvent) {
    const tenantId = getCurrentTenant();
    event.queryRunner.query(
      `SET LOCAL ROLE tenantkit_app; SELECT set_config('app.current_tenant', $1, true)`,
      [tenantId]
    );
  }
}

Now every query on `documents` is automatically filtered by `tenant_id` — even if the developer forgets to add a WHERE clause. RLS catches it.

The payoff

  • **Zero cross-tenant leakage** even when developers forget WHERE clauses
  • **No performance overhead** — RLS uses the same index as the WHERE clause
  • **Defense-in-depth** — application-layer filtering + RLS = two independent checks

The lesson: tenant isolation is a database problem, not an application problem. Push it as deep as you can.