Tüm yazılarAll posts

Multi-Tenant SaaS Mimarisi: RLS, Subdomain ve Auth

Multi-Tenant SaaS Architecture: RLS, Subdomain & Auth

Tek bir Supabase instance'tan binlerce tenant'a hizmet vermek için PostgreSQL Row Level Security, Next.js middleware subdomain tespiti ve OAuth akışlarını nasıl bağladığımı gösteriyorum.

Here's how I wired up PostgreSQL Row Level Security, Next.js middleware subdomain detection, and OAuth flows to serve thousands of tenants from a single Supabase instance.

Multi-tenant SaaS architecture: 5 client tenants (Premium, Enterprise, Global, Startup, Scale) connected to a single Supabase database with RLS-isolated rows

Problem: binlerce müşteri, tek instance

İlk CRM müşterim 30 lead ile geldi. İkinci müşteri 200 lead. Üçüncüsü 1500. Her biri için ayrı database kurmak (ayrı Supabase projesi) teknik borç: migration, backup, monitoring, fatura — hepsi 30 kat artıyor. Çözüm: tek Supabase, çoklu tenant.

Ama dikkat: tenant'ların birbirinin verisini görmemesi lazım. SQL injection'a karşı bile. Bu, veri izolasyonu ve auth'un beraber düşünülmesi gerektiği anlamına geliyor.

PostgreSQL Row Level Security

PostgreSQL'in RLS özelliği tam bu iş için. Her tabloda tenant_id kolonu olacak. Her sorguda SET app.current_tenant = 'tenant-a-id' çalıştırılacak. RLS politikası da sadece o tenant'ın satırlarını gösterecek:

-- Tablo
CREATE TABLE leads (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  email TEXT,
  phone TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- RLS aktif
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;

-- Politika: sadece mevcut tenant'ın leadlerini göster
CREATE POLICY tenant_isolation ON leads
  USING (tenant_id = current_setting('app.current_tenant')::UUID);

Bu sayede uygulama kodunda WHERE tenant_id = ? yazmayı unutsam bile veritabanı sızdırmıyor. Güvenlik veritabanı katmanında.

Next.js middleware: subdomain tespiti

Her müşteri kendi subdomain'inde oturuyor: acme.app.com, globex.app.com. Next.js middleware gelen request'in host'undan tenant'ı çıkarıp, app.current_tenant'ı set ediyor:

// middleware.ts
import { NextResponse } from 'next/server';
export async function middleware(request) {
  const host = request.headers.get('host');
  const subdomain = host.split('.')[0];
  const tenant = await getTenantBySlug(subdomain);
  if (!tenant) return NextResponse.redirect(new URL('/404', request.url));
  const response = NextResponse.next();
  response.headers.set('x-tenant-id', tenant.id);
  return response;
}

Supabase bağlantısı kurulurken bu header'dan tenant_id alınıp, SET app.current_tenant = ... çalıştırılıyor. RLS + middleware = sıfır manuel filtreleme.

OAuth ve JWT

Supabase Auth Google OAuth provider'ı ile geliyor. JWT'ye custom claim olarak tenant_id eklemek istedim ama Supabase bunu doğrudan desteklemiyor. Çözüm: auth.users tablosuna tenant_id kolonu ekleyip, JWT'de bunu app_metadata üzerinden taşımak. Signup sırasında subdomain'den tenant slug alınıp, yeni kullanıcı o tenant'a atanıyor.

Server tarafında her Supabase sorgusu için: supabase.auth.setSession(jwt); supabase.rpc('set_tenant', { tenant_id }). Bu, RLS context'i hazırlıyor.

Performans tuzağı

İlk production deployment'ta her sorgu ~80ms sürüyordu. RLS'in overhead'i sanıyordum. Profil açtım: SET app.current_tenant = ... her sorguda çalışıyordu. Connection pooling ile bunu connection başına bir kere yapılacak şekilde optimize ettim:

// Supabase bağlantısı kurulduğunda
await supabase.rpc('set_tenant_context', { tenant_id });

Query time 80ms'den 12ms'ye düştü. RLS overhead'i sanılanın aksine sadece 2-3ms'ydi, asıl darboğaz connection setup'tı.

Tam izolasyon için ek önlemler

RLS veri sızıntısını engelliyor ama dosya yükleme (Storage) için aynı şey geçerli değil. Supabase Storage'da her tenant için ayrı bucket oluşturdum. Bucket adı = tenant slug. RLS politikası sadece kendi bucket'ına erişime izin veriyor.

Realtime subscriptions (websocket) da izolasyon gerektiriyor. Postgres LISTEN/NOTIFY kanalına tenant_id ekleyip, client-side'da filtreliyorum.

Çıkarılan dersler

Birincisi: RLS'i baştan tasarla, sonra ekleme. Sonradan eklemek migration kabusu. İlk tablodan itibaren tenant_id kolonu + RLS politikası şart.

İkincisi: subdomain seçimini müşteriye bırak. Slug'lar benzersiz olmalı, DNS yönetimi senin işin değil. Müşteri acme.yourapp.com istiyorsa, sen DNS'te CNAME ekle, onlar subdomain'i yönetir.

Üçüncüsü: veri izolasyonu tek seferde düşünme. Tablo, storage, cache, search index, log — hepsi izolasyon gerektirir. Bir tanesi unutulursa ciddi güvenlik açığı doğar. Audit checklist oluştur, her yeni özellik bu listeye göre review edilir.

The problem: thousands of customers, one instance

My first CRM customer came in with 30 leads. The second had 200. The third had 1500. Standing up a separate database for each (separate Supabase project) means technical debt multiplies: migrations, backups, monitoring, billing — 30x. Solution: one Supabase, many tenants.

But with a critical caveat: tenants must never see each other's data. Even in the face of SQL injection. That means data isolation and auth have to be designed together.

PostgreSQL Row Level Security

PostgreSQL's RLS feature fits exactly. Every table has a tenant_id column. Every query runs SET app.current_tenant = 'tenant-a-id'. RLS policy then only returns rows for that tenant:

-- Table
CREATE TABLE leads (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  email TEXT,
  phone TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Enable RLS
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;

-- Policy: only show leads for the current tenant
CREATE POLICY tenant_isolation ON leads
  USING (tenant_id = current_setting('app.current_tenant')::UUID);

Now even if app code forgets WHERE tenant_id = ?, the database doesn't leak. Security at the data tier.

Next.js middleware: subdomain detection

Each customer gets their own subdomain: acme.app.com, globex.app.com. Next.js middleware reads the incoming request's host, extracts the tenant, sets app.current_tenant:

// middleware.ts
import { NextResponse } from 'next/server';
export async function middleware(request) {
  const host = request.headers.get('host');
  const subdomain = host.split('.')[0];
  const tenant = await getTenantBySlug(subdomain);
  if (!tenant) return NextResponse.redirect(new URL('/404', request.url));
  const response = NextResponse.next();
  response.headers.set('x-tenant-id', tenant.id);
  return response;
}

When the Supabase connection is established, the tenant_id comes from this header and SET app.current_tenant = ... runs. RLS + middleware = zero manual filtering.

OAuth and JWT

Supabase Auth comes with a Google OAuth provider. I wanted to add tenant_id as a custom claim to the JWT, but Supabase doesn't directly support that. Workaround: add a tenant_id column to auth.users, then surface it in app_metadata of the JWT. At signup, we read the subdomain's tenant slug and assign the new user.

On the server, for every Supabase query: supabase.auth.setSession(jwt); supabase.rpc('set_tenant', { tenant_id }). This sets up the RLS context.

The performance trap

On the first production deploy, every query took ~80ms. I assumed it was RLS overhead. I profiled and found SET app.current_tenant = ... was running on every query. I optimized it to run once per connection via connection pooling:

// On Supabase connection setup
await supabase.rpc('set_tenant_context', { tenant_id });

Query time dropped from 80ms to 12ms. RLS overhead is only 2-3ms — the real bottleneck was connection setup.

Extra measures for full isolation

RLS prevents data leaks but file uploads (Storage) need the same treatment. In Supabase Storage I create a separate bucket per tenant. Bucket name = tenant slug. RLS policy only allows access to the tenant's own bucket.

Realtime subscriptions (websocket) also need isolation. I add tenant_id to the Postgres LISTEN/NOTIFY channel and filter on the client side.

Lessons learned

First: design RLS from day one, don't bolt it on. Retrofitting RLS is a migration nightmare. Every table from the start needs a tenant_id column and an RLS policy.

Second: let the customer choose the subdomain. Slugs must be unique, DNS management isn't your job. If a customer wants acme.yourapp.com, you add a CNAME; they handle their subdomain.

Third: data isolation isn't a one-time concern. Tables, storage, cache, search index, logs — all need isolation. Forget one and you have a serious security hole. Build an audit checklist. Every new feature gets reviewed against that list.

ÖA
Ömer Faruk Aydın
Computer Programmer · AI Integrator · Full-Stack Developer · İstanbul
Computer Programmer · AI Integrator · Full-Stack Developer · Istanbul