We build on Supabase because the security model is real
Postgres with row-level security, Auth that ships in a week, Storage with signed URLs, Realtime when it earns its place. Lock-in free because the database is just Postgres.
Why this stack
Row-level security as a real authorisation layer
Most apps put authorisation in the application code and then write a separate document explaining why customer A cannot see customer B. With RLS the answer is a SQL policy that fails closed, runs on every query, and you can put a screenshot of it in the security review.
Postgres, not a proprietary database
The day the project moves off Supabase, the database moves with it. We have done it. No surprise lock-in, no proprietary query language, no per-row pricing model that breaks at scale. Just Postgres.
Auth that does not need a separate service
Social providers, magic links, OTP, MFA, SSO, server-side cookies, all in the same project as the data. No webhook from Auth0 to your database to keep two user tables in sync.
Type safety generated from the live schema
We generate the TypeScript types from the deployed schema. A renamed column breaks the build before it ships, not a paying customer's report.
Storage, Realtime, Edge Functions in the same place
One project handles attachments with signed URLs, live updates over WebSocket, and webhook ingestion at the edge. Three separate services collapse into one and the team learns one mental model.
What we build with it
Row-level security policies, audited and versioned
Policies live in source-controlled migrations, not in the database UI. Every change goes through review and has an automated test.
Multi-tenant data model
Members table with role enum (owner / admin / member / billing), policies that scope every read and write to the active tenant.
Auth with social providers and MFA
Google, GitHub, Apple, magic links, SSO via SAML where the buyer needs it. Enrolment, recovery codes, device management.
Storage with signed URLs and bucket policies
Per-tenant buckets, signed URLs that expire, image transformations at the edge, RLS on storage objects.
Realtime subscriptions where they earn their place
WebSocket-backed live updates for inboxes, dashboards, presence indicators. Selective by RLS, so a client only sees rows it is allowed to.
Edge Functions for webhook ingestion
Stripe, Resend, third-party webhooks land at the edge, validate signatures, write back through the service-role client.
Versioned migrations and seeds
Migrations in source control with up/down, seed scripts that survive a database reset, CI that runs them on every PR.
Generated TypeScript types pipeline
An npm script regenerates the database types after every migration. The build fails if the types are stale.
Backup, point-in-time recovery, restore drill
PITR enabled, off-site dump cadence, a documented restore drill that you can actually run before the day you need it.
Self-hosting blueprint
When the procurement environment requires self-host, we ship a docker-compose that mirrors the cloud stack and a runbook the next team can operate.
Performance: indexes, plans, query budgets
Every hot query has an index, a measured plan, and a budget. Slow query log goes into the dashboard, not into an inbox no one reads.
Migration from Firebase, PlanetScale, raw Postgres
Existing data moved, foreign keys re-established, auth users carried over with hashed-password migration where supported.
Multi-tenant authorisation with row-level security, in code
The members table is the only source of truth for who belongs to which tenant. Every other table delegates to it via RLS. A user that switches tenants flips the policy result without a code deploy.
Most "multi-tenant SaaS on Supabase" tutorials show you how to add a tenant_id column and pretend that solves authorisation. It does not. The pattern below is the one we ship to production: a members join table that owns the tenant–user relationship, plus row-level security policies that delegate every authorisation check to it.
1. The shape of the tables
A user belongs to many tenants through a members row. Each members row carries the role. Every other table that needs tenant scoping has a tenant_id and trusts the policy to enforce it.
-- supabase/migrations/0001_tenants.sql
create table tenants (
id uuid primary key default gen_random_uuid(),
name text not null,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create type member_role as enum ('owner', 'admin', 'member', 'billing');
create table members (
tenant_id uuid not null references tenants(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role member_role not null,
joined_at timestamptz not null default now(),
primary key (tenant_id, user_id)
);
create index members_user_idx on members(user_id);
create table tickets (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants(id) on delete cascade,
title text not null,
body text,
status text not null default 'open',
assignee_id uuid references auth.users(id),
created_at timestamptz not null default now()
);
create index tickets_tenant_idx on tickets(tenant_id, status, created_at desc);
2. The row-level security policies
Policies enable RLS on the table and define what is readable, writable and updatable. Every policy delegates to a subquery against members so a single source of truth controls every check.
-- supabase/migrations/0002_tickets_rls.sql
alter table tickets enable row level security;
create policy "tickets: tenant members can read"
on tickets for select
using (
tenant_id in (select tenant_id from members where user_id = auth.uid())
);
create policy "tickets: tenant members can insert"
on tickets for insert
with check (
tenant_id in (select tenant_id from members where user_id = auth.uid())
);
create policy "tickets: admins can update"
on tickets for update
using (
tenant_id in (
select tenant_id from members
where user_id = auth.uid() and role in ('owner', 'admin')
)
);
create policy "tickets: only owners can delete"
on tickets for delete
using (
tenant_id in (
select tenant_id from members
where user_id = auth.uid() and role = 'owner'
)
);
3. The Next.js Server Component reading the data
The application code stops carrying authorisation. The client (here a Next.js Server Component) reads tickets as if the user could see everything; the database tells the truth.
// app/[lang]/(app)/tickets/page.tsx
import { createServerClient } from '@/lib/supabase/server'
export default async function TicketsPage() {
const supabase = await createServerClient()
const { data: tickets, error } = await supabase
.from('tickets')
.select('id, title, status, created_at')
.eq('status', 'open')
.order('created_at', { ascending: false })
if (error) throw error
if (!tickets) return null
return (
<ul>
{tickets.map((t) => (
<li key={t.id}>
<a href={`/tickets/${t.id}`}>{t.title}</a>
</li>
))}
</ul>
)
}
4. The generated types
We run supabase gen types typescript --linked > src/types/db.ts after every migration. The generated file is committed; the CI fails if it drifts from the deployed schema. The tickets row becomes a Tables<'tickets'> type the application uses everywhere.
// src/lib/supabase/server.ts
import { cookies } from 'next/headers'
import { createServerClient as createSupabaseServerClient } from '@supabase/ssr'
import type { Database } from '@/types/db'
export async function createServerClient() {
const cookieStore = await cookies()
return createSupabaseServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (toSet) => {
for (const { name, value, options } of toSet) {
cookieStore.set(name, value, options)
}
},
},
},
)
}
5. What this buys you
A new column in tickets, a new tenant role, a new policy: each one is a single SQL migration that goes through review. The authorisation document the next enterprise buyer asks for is a screenshot of the policies file. The next team that opens the codebase reads the same SQL we read, not a custom abstraction layer.
Multi-tenant security stops being a feature you ship; it becomes a property of the database.
Frequently asked questions
Why Supabase instead of raw Postgres on RDS?
The Postgres is the same. What you save is the months building Auth, Storage, Realtime, and the operational layer around them. If you have an in-house DBA team and a Devex team, raw Postgres is fine. Most teams do not.
Self-hosted or Supabase Cloud?
Cloud by default. Self-host when the buyer requires on-premise (public sector, regulated finance, certain healthcare). The application code does not change between the two; only the connection string does.
Can you migrate from Firebase or PlanetScale?
Yes. Firebase is the more common ask. Users, custom claims, Firestore documents and Storage objects all map to Postgres + RLS + Storage buckets. Plan the migration as a paid scoping engagement; the cost depends on data shape and auth model.
How does row-level security perform at scale?
A well-indexed policy adds one or two milliseconds per query. A badly-written policy can scan the whole table. We measure every policy with `explain analyze` before shipping and add indexes that match the policy predicates.
What about backup and disaster recovery?
Point-in-time recovery to any second in the last seven days on Cloud, longer windows on enterprise plans. Off-site dumps to a bucket you own. A documented restore drill we run with you once before launch.
How do you handle generated types when the schema changes?
An npm script regenerates the types from the deployed schema. The CI pipeline runs it on every PR; if the generated types drift from the committed ones, the build fails. No silent type lies.
Can we use Supabase Auth with our existing identity provider?
Yes. SAML SSO for enterprise buyers, OIDC for federated identity, custom JWT for legacy systems. Existing users migrate via the admin API with their original IDs preserved.
Tell us what you are building on Supabase
A scoping call, a concrete number in the first reply, no agency theater. RLS-first apps in three to six weeks.