← Back to all posts
October 9, 20254 min read

How We Migrated InvoicifyAI to Company-Based Multi-Tenancy

Lessons from moving 70+ tables from user-scoped to company-scoped multi-tenancy in Supabase/PostgreSQL without downtime.

#supabase#postgresql#multi-tenancy#saas#security

Most data leaks in multi-tenant SaaS apps start in the database. We recently completed a full migration of InvoicifyAI from a user-scoped model to strict company-based multi-tenancy across more than 70 tables. We kept production online the entire time.

This post documents the playbook we followed, the pitfalls we tripped over, and the defenses we refuse to compromise on now that the migration is complete.

Why we changed course

In the early days we scoped almost every table by user_id. It worked—until it didn't. Cross-company cache bleed, confusing RLS policies, and awkward storage paths forced engineers to think too hard about every query. Multi-tenant voice agents amplified the risk: one missed filter could leak recordings or invoices between tenants.

Moving everything to company_id gave us a single mental model:

  • Company = tenant — every record carries the company boundary.
  • Users join companies — access flows through membership and roles.
  • Subscriptions belong to companies — billing, feature flags, and voice-agent usage inherit company state.

Defense-in-depth or bust

Row Level Security is mandatory in Supabase, but we treat it as the *last* line of defense, not the first. Every query now includes an explicit company filter at the application layer:

const { companyId } = useCompanyContext();

const { data, error } = await supabase
  .from("invoices")
  .select("*")
  .eq("company_id", companyId);

The same rule applies to RPC functions, Edge Functions, and storage paths. If an action can access tenant data, it validates company context before doing anything else.

Query keys you can trust

Switching workspaces used to leave stale data on screen because React Query caches were keyed by simple arrays like ['invoices']. We introduced a query keys factory that always scopes cache entries by company:

export const queryKeys = {
  invoices: {
    all: (companyId: string) => ["invoices", { companyId }],
  },
};

When a user switches companies, we clear caches tied to the previous companyId, eliminating cross-tenant bleed and mysterious UI flashes.

Storage isolation everywhere

Nothing leaves storage unless it passes two checks:

  1. The path starts with the correct company prefix (company_id/resource/...).
  2. We confirm the requesting user belongs to that company before minting signed URLs.

That change alone prevented a handful of “almost incidents” where a shared signed URL could have exposed invoice PDFs across tenants.

Write-with-read migration (WWR)

Table rewrites are expensive and risky, so we leaned on a write-with-read (WWR) pattern:

  1. Backfill — populate company_id using existing user_id relationships.
  2. Dual-write — update the application layer to set both user_id and company_id.
  3. Verify — run audit queries comparing old and new scopes until the mismatch rate hits 0.00%.
  4. Flip reads — switch all reads to filter by company_id and remove the old fallbacks.

Because the app dual-wrote for a full release cycle before we flipped the reads, we caught edge cases (like legacy invoices without owners) without customers seeing errors.

Pitfalls we fixed along the way

  • Legacy RLS policies used OR conditions mixing user_id and company_id. We replaced them with simple company checks and leaned on membership tables for roles.
  • Loose storage helpers called createSignedUrl without validating the path prefix. Every helper now asserts that the requested path begins with the caller's company ID.
  • RPCs missing validation assumed RLS would prevent cross-company access. We revoked those functions, added explicit checks, and granted execution only to authenticated after review.

Testing discipline

The migration only shipped after passing three gates:

  1. Local Supabase migrations (supabase migration up) with targeted scripts to compare row counts and sample data.
  2. Supabase development branch (MCP) to run integration tests and confirm RLS policies behaved as expected in a shared environment.
  3. Production rollout with a rollback plan and live monitoring to catch unexpected cache misses or storage errors.

What changed for the team

  • Cleaner mental model — engineers know every query needs .eq("company_id", companyId) and every insert sets company_id + created_by_user_id.
  • Safer automation — AI agents act through RPCs that enforce tenant boundaries, which makes audits easier.
  • Simpler onboarding — new developers ramp faster because they see the same pattern in every module.

Grab the checklist

We built a multi-tenant checklist that covers RPCs, storage, RLS, Supabase policies, and React Query patterns. If you want a copy, reply “multi-tenant” on our LinkedIn post or email us.

Want help applying the same pattern? Reach out at hello@invoicifyai.com.

Related posts