DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Multi-tenant SaaS with Claude Code: Tenant Isolation and Row Level Security

Tenant data leaks between tenants = catastrophic bug. It's not just a data breach — it destroys trust and can end your SaaS business overnight. Claude Code generates tenant isolation automatically from CLAUDE.md rules, so you never accidentally expose one tenant's data to another.

CLAUDE.md Tenant Isolation Rules

Add these rules to your CLAUDE.md and Claude Code enforces them across every file it touches:

## Database Security Rules
- All DB queries must include tenantId filter
- tenantId comes from JWT (never from request params)
- All tables need tenantId NOT NULL constraint
- Middleware sets req.tenant on every request
- BaseRepository auto-applies tenant filter
- Use AsyncLocalStorage for context propagation
- PostgreSQL RLS as backup isolation layer
Enter fullscreen mode Exit fullscreen mode

Generating Tenant Isolation Code

Prompt:

Create tenant isolation middleware and base repository using AsyncLocalStorage.
Follow CLAUDE.md database security rules.
Enter fullscreen mode Exit fullscreen mode

AsyncLocalStorage Middleware

Claude Code generates context-safe tenant propagation:

import { AsyncLocalStorage } from 'async_hooks';

const tenantContext = new AsyncLocalStorage<{ tenantId: string }>();

export function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
    // tenantId comes from JWT — never trust request params
    tenantContext.run({ tenantId: payload.tenantId }, () => {
      req.tenant = { id: payload.tenantId };
      next();
    });
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

export function getCurrentTenantId(): string {
  const store = tenantContext.getStore();
  if (!store) throw new Error('TenantContext not initialized — missing middleware?');
  return store.tenantId;
}
Enter fullscreen mode Exit fullscreen mode

The getCurrentTenantId() function throws if called outside the middleware context. This means a missing tenantMiddleware causes an immediate error — not a silent data leak.

BaseRepository with Auto-Applied Tenant Filter

abstract class BaseRepository<T> {
  protected abstract tableName: string;

  protected get tenantId(): string {
    return getCurrentTenantId(); // throws if context missing
  }

  async findById(id: string): Promise<T | null> {
    return prisma[this.tableName].findFirst({
      where: { id, tenantId: this.tenantId }, // always filtered
    });
  }

  async findMany(where: Partial<T> = {}): Promise<T[]> {
    return prisma[this.tableName].findMany({
      where: { ...where, tenantId: this.tenantId },
    });
  }

  async create(data: Omit<T, 'tenantId' | 'id'>): Promise<T> {
    return prisma[this.tableName].create({
      data: { ...data, tenantId: this.tenantId }, // injected automatically
    });
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    // Check ownership before updating
    const existing = await this.findById(id);
    if (!existing) throw new Error('Not found or access denied');

    return prisma[this.tableName].update({
      where: { id },
      data,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Every method automatically scopes to the current tenant. Developers can't forget the filter — it's baked into the base class.

UserRepository Example

class UserRepository extends BaseRepository<User> {
  protected tableName = 'user';

  async findByEmail(email: string): Promise<User | null> {
    return this.findMany({ email }).then(users => users[0] ?? null);
    // tenantId filter applied automatically by findMany
  }

  async updateProfile(userId: string, profile: UpdateProfileDto): Promise<User> {
    return this.update(userId, profile);
    // ownership check + tenant filter both applied by update()
  }
}
Enter fullscreen mode Exit fullscreen mode

No explicit tenantId in application code — the base class handles it all.

PostgreSQL RLS as Backup Layer

Even if application code has a bug, RLS prevents cross-tenant access at the database level:

-- Enable RLS on all tenant-scoped tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Policy: rows only visible when tenant_id matches session variable
CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
Enter fullscreen mode Exit fullscreen mode

Prisma middleware sets the session variable before every query:

prisma.$use(async (params, next) => {
  const tenantId = getCurrentTenantId();
  await prisma.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
  return next(params);
});
Enter fullscreen mode Exit fullscreen mode

Now even raw SQL queries or direct DB connections respect tenant boundaries.

Defense in Depth Summary

Layer Mechanism What It Prevents
JWT tenantId from token Tenant spoofing via params
AsyncLocalStorage Context propagation Accidental tenantId omission
BaseRepository Auto-applied filter Missing WHERE clauses
PostgreSQL RLS DB-level policy Bypassed application logic

Claude Code generates all four layers from a single CLAUDE.md file. Add the rules once — get consistent enforcement across the entire codebase.


Security Pack (¥1,480) — Use /security-check to scan your SaaS for tenant isolation vulnerabilities, missing RLS policies, and JWT misconfiguration.

Available at prompt-works.jp

Top comments (0)