How we designed Edos Poa's multi-tenant POS SaaS on Supabase — schema isolation, RLS, and billing.
EdosPoa is a multi-tenant Point of Sale and business management SaaS that we designed and built on Supabase. With 50+ tenants and 500 million records in production, here is what we learned about multi-tenancy on Supabase.
The Three Multi-Tenancy Models
Schema per tenant: Each tenant gets their own PostgreSQL schema. Strongest isolation, easiest per-tenant backups, but schema management complexity scales with tenant count. Supabase does not natively support schema-per-tenant out of the box.
Database per tenant: Maximum isolation — separate Supabase project per tenant. Prohibitively expensive at scale.
Shared schema with tenant ID: All tenants share the same tables. Every row has a tenant_id column. Row Level Security (RLS) filters data by tenant. This is what we chose, and it is what Supabase is optimised for.
Row Level Security: The Core Architecture Decision
In a shared-schema multi-tenant database, RLS is not optional — it is the security boundary between tenants. Every table that contains tenant-specific data has an RLS policy using the tenant_id column matched against the authenticated user's tenant membership.
The key performance consideration: a subquery to resolve tenant_id from the user's profile runs on every row evaluation. On a table with 10 million rows, this is expensive. We replaced it with a JWT claims approach where the tenant ID is embedded in the access token at login time.
JWT Claims for Tenant Context
We set the tenant ID in the Supabase JWT using a custom app.tenant_id claim via a database hook on auth.sign_in. The hook function resolves the user's tenant from the tenant_members table and embeds it in the token's app_metadata. RLS policies then read from the JWT directly — without a subquery — which is 10–20× faster on large tables.
Schema Design
We ended up with three schemas: public (shared entities — tenants, tenant_members, subscription_plans), app (operational data — sales, products, inventory, customers — all with tenant_id and RLS), and analytics (pre-aggregated reporting tables rebuilt nightly by pg_cron — no RLS needed since data is pre-filtered during aggregation).
Performance at Scale
By month 8, our largest tenant had 50 million rows in the sale_items table. Query performance degraded noticeably. The fixes: partial indexes on tenant_id + created_at, PgBouncer connection pooling tuned to our p99 concurrency, and analytics pre-aggregation (moving summary calculations from query-time to nightly materialized views).
Billing Integration
We integrated Stripe for international customers and M-Pesa for Kenya-based businesses. All billing state lives in our database, not in Stripe alone — we sync Stripe webhooks into a billing_events table and re-derive subscription state from events. For M-Pesa, we use the Daraja STK Push API with a callback URL hitting a Supabase Edge Function.
The RLS Gotcha That Cost Us a Week
Supabase's service role key bypasses RLS entirely. Every background job or admin operation that uses the service role key has direct access to all tenant data. Document this clearly in your team — a developer who accidentally uses the service role key in client-side code breaks your entire isolation model.
Would We Use Supabase Again?
Yes, without hesitation. The combination of PostgreSQL, RLS, realtime subscriptions, Edge Functions, and built-in auth handles 90% of what a production SaaS needs. For a team of 2–4 engineers shipping a production SaaS, the productivity gain from Supabase's managed infrastructure is substantial.
