How Supabase Works Internally: PostgREST, GoTrue Auth, Realtime Subscriptions, and the PostgreSQL Platform Architecture
A deep dive into how Supabase stitches together PostgREST, GoTrue, a Realtime server, S3-compatible storage, Deno Edge Functions, and Kong into a coherent backend platform on top of PostgreSQL.
Most backend platforms hide the database behind an abstraction layer. Supabase inverts that: the database is the platform. PostgreSQL is not a backend implementation detail; it is the API contract, the authorization model, the replication source for real-time events, and the canonical state store. Everything else in the Supabase architecture is a thin adapter that exposes what PostgreSQL already knows how to do.
Understanding how that actually works in practice, at the protocol level, changes how you reason about the guarantees, the failure modes, and the cases where the platform fits badly.
The Architecture at a Glance
A Supabase project is a composition of six services, all fronted by a Kong API gateway:
- PostgreSQL (the single source of truth)
- PostgREST (REST API auto-generated from the database schema)
- GoTrue (authentication and session management)
- Realtime (WebSocket server driven by PostgreSQL logical replication)
- Storage API (object storage with RLS-based access control)
- Edge Functions (Deno Deploy runtime for custom server-side logic)
Kong sits in front of all of them on port 443, routing requests by path prefix and injecting JWT claims as headers before they reach each downstream service. There is no application server in the traditional sense. Business logic lives either in PostgreSQL itself (functions, triggers, RLS policies) or in Edge Functions.
PostgREST: Schema Introspection as API
PostgREST is a standalone web server written in Haskell. On startup and after a NOTIFY pgrst, 'reload schema' event, it queries the information_schema and pg_catalog to build an in-memory representation of every table, view, function, and column in the public schema (and any schemas listed in db_schema).
That representation becomes the routing table. A table called orders with columns id, user_id, status, and created_at automatically produces:
GET /rest/v1/orders -- SELECT with filtering, ordering, pagination
POST /rest/v1/orders -- INSERT
PATCH /rest/v1/orders?id=eq.5 -- UPDATE with PostgREST filter syntax
DELETE /rest/v1/orders?id=eq.5 -- DELETE
The filter syntax (eq, gt, lt, like, in, is, not) maps directly to SQL WHERE clauses that PostgREST constructs via parameterized queries. The select query parameter maps to a SQL column list. The order parameter maps to ORDER BY. Range headers (Range: 0-49) map to LIMIT and OFFSET.
PostgREST never generates dynamic SQL strings from raw user input. Every user-supplied value goes through prepared statement parameters. The SQL shape is determined entirely by the schema introspection result.
Embedded resources work through foreign key relationships. If orders has a foreign key to users, you can fetch them together:
const { data } = await supabase
.from('orders')
.select('id, status, users(email, name)')
.eq('status', 'pending')
PostgREST translates this to a single SQL query using a JSON aggregation join:
SELECT
orders.id,
orders.status,
row_to_json(users.*) AS users
FROM orders
JOIN users ON orders.user_id = users.id
WHERE orders.status = 'pending'
This means N+1 query patterns are structurally impossible from the PostgREST layer. Every nested resource select is a single database round-trip.
The Role Set Context
Before PostgREST executes any query, it issues a SET LOCAL to configure the PostgreSQL transaction context:
SET LOCAL role = 'authenticated';
SET LOCAL request.jwt.claims = '{"sub":"uuid","role":"authenticated"}';
SET LOCAL request.jwt.claim.sub = 'uuid';
This is how Row Level Security policies access the currently authenticated user. An RLS policy on orders that reads auth.uid() resolves to current_setting('request.jwt.claim.sub')::uuid. The JWT claim is not passed as a query parameter; it is injected into the transaction session, and PostgreSQL’s RLS engine reads it during plan execution. The authorization check is atomic with the data access.
GoTrue: Authentication as a PostgreSQL-Adjacent Service
GoTrue is an authentication microservice written in Go, originally from Netlify Identity and forked by the Supabase team. It manages:
- User records stored in the
authschema of the same PostgreSQL cluster - Session tokens as signed JWTs (HS256 with your project’s JWT secret, or RS256 for advanced configurations)
- OAuth 2.0 provider flows (Google, GitHub, Apple, and others)
- Email/password, phone OTP, and magic link flows
- Multi-factor authentication via TOTP and phone
When a user signs in, GoTrue:
- Validates credentials against the
auth.userstable - Issues an access token (short-lived JWT, default 3600 seconds) and a refresh token (long-lived opaque token stored in
auth.refresh_tokens) - Returns both to the client
The access token is a standard JWT. The role claim is set to authenticated for logged-in users and anon for unauthenticated requests. PostgREST reads this claim to set the PostgreSQL role, which activates the correct RLS policies.
// What the decoded JWT looks like
{
"aud": "authenticated",
"exp": 1718467200,
"sub": "a1b2c3d4-...", // maps to auth.users.id
"email": "user@example.com",
"role": "authenticated",
"app_metadata": { "provider": "email" },
"user_metadata": {}
}
GoTrue does not call PostgREST. It writes directly to the auth schema over the same PostgreSQL connection. User sign-ups create rows in auth.users. Your application code can join auth.users in SQL functions but should not query it directly from PostgREST (the auth schema is not exposed by default for this reason).
The practical pattern is to maintain a public.profiles table with a foreign key to auth.users.id and a database trigger that creates the profile row on user creation:
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, email)
VALUES (new.id, new.email);
RETURN new;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();
This keeps application data in the public schema and auth identity in the auth schema, with the trigger as the only coupling point.
Realtime: Logical Replication as a WebSocket Feed
The Realtime server is written in Elixir. It connects to PostgreSQL as a logical replication subscriber using the pgoutput plugin (the same protocol underlying tools like Debezium and pglogical).
PostgreSQL logical replication works by streaming a write-ahead log (WAL) decode to subscribers. When a row is inserted, updated, or deleted on a table that has REPLICA IDENTITY set (full or default), the WAL record includes the old and new column values. The Realtime server receives these records and decides which connected WebSocket clients should receive each event.
There are three subscription modes:
- Broadcast: clients send messages to a channel; Realtime fan-outs to all subscribers without touching the database
- Presence: distributed state tracking of who is online in a channel, backed by the Realtime cluster’s in-memory store
- Postgres Changes: database change events derived from the WAL stream, filtered by table, schema, and a
filterpredicate
const channel = supabase
.channel('orders-changes')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'orders',
filter: 'status=eq.pending'
},
(payload) => {
console.log('New pending order:', payload.new)
}
)
.subscribe()
Under the hood the Realtime server evaluates the RLS policies of the subscribing user against each incoming WAL record before forwarding it. A user subscribed to orders changes will only receive events for rows they are permitted to SELECT. This is enforced in the Realtime server process by running a verification query against PostgreSQL for each event.
One important constraint: the WAL stream operates at the database level. Realtime receives every change on subscribed tables and filters client-side in the Elixir process. At high write throughput this can become a bottleneck; the filter predicate in the subscription reduces the payload forwarded to clients but does not reduce what the Realtime server reads from the WAL.
Storage: Object Storage with RLS
The Storage API is a Node.js service that wraps S3-compatible object storage (S3 itself on the managed platform, MinIO for self-hosted deployments). Buckets, objects, and ownership metadata are stored in the storage schema of the same PostgreSQL cluster.
Access control for storage objects uses RLS policies on the storage.objects table:
-- Allow authenticated users to upload to their own folder
CREATE POLICY "Users can upload to own folder"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Allow public read on a bucket
CREATE POLICY "Public read on avatars"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
When a client requests a signed URL or a direct download, the Storage API checks storage.objects against the RLS policies using the caller’s JWT, then either proxies the S3 request or returns a pre-signed URL. The actual bytes flow from S3 directly to the client or through the Storage service depending on whether the bucket is public.
This design means object storage access control uses the same language and evaluation model as database access control. You do not maintain two separate authorization systems.
Edge Functions: Deno at the Edge
Edge Functions run on Deno Deploy (managed) or a self-hosted Deno server. They are the escape hatch for logic that cannot be expressed as SQL, RLS policies, or PostgREST calls: webhooks, third-party API integrations, custom auth flows, and heavy computation.
// supabase/functions/send-welcome-email/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { record } = await req.json()
await sendEmail({
to: record.email,
subject: 'Welcome',
body: `Hello ${record.email}`
})
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' }
})
})
Edge Functions can be invoked directly from clients or triggered by database webhooks (using pg_net or the Supabase Database Webhooks feature, which fires an HTTP request on table events). The SUPABASE_SERVICE_ROLE_KEY bypasses RLS, which is why Edge Functions that use it should only run in trusted server contexts.
Kong: The API Gateway Layer
Kong routes every inbound request to the correct service:
| Path prefix | Upstream service |
|---|---|
/rest/v1/ | PostgREST |
/auth/v1/ | GoTrue |
/realtime/v1/ | Realtime (WebSocket upgrade) |
/storage/v1/ | Storage API |
/functions/v1/ | Edge Functions runtime |
Kong also handles JWT verification before requests reach PostgREST, extracts the anon key or service_role key from the apikey header, and enforces rate limits. The anon key is a pre-signed JWT with role: anon. The service_role key is a pre-signed JWT with role: service_role (which bypasses RLS). Both are just JWTs signed with the project’s secret; they are not credentials in the traditional sense.
Connection Pooling via Supavisor
Direct PostgreSQL connections are expensive. Each connection allocates roughly 5-10 MB of memory in PostgreSQL and has a CPU cost for the session setup. A project with many concurrent API requests cannot open one connection per request.
Supavisor is Supabase’s connection pooler, written in Elixir. It replaces the older pgBouncer-per-project approach with a cluster-aware pooler that can proxy millions of client connections to a much smaller pool of actual PostgreSQL server connections.
Supavisor supports two modes:
- Transaction mode: a PostgreSQL connection is borrowed from the pool for the duration of a single transaction, then returned. This maximizes pool efficiency but means you cannot use session-level features like
LISTEN/NOTIFYorSETcommands that persist across queries (PostgREST handles this by usingSET LOCALwithin transactions). - Session mode: a dedicated PostgreSQL connection is held for the lifetime of the client connection. Used for migrations and administrative tasks.
The connection string for Supavisor uses port 6543 instead of 5432. Direct connections to PostgreSQL remain available on port 5432 for migration tools and for cases where session-level behavior is needed.
Production Considerations
Self-hosted vs. managed: The managed Supabase platform handles the operability of all six services, automated backups, point-in-time recovery, and the PostgreSQL major version upgrades. Self-hosting requires running and updating each service independently. The Docker Compose setup in supabase/supabase is the reference deployment, but it is not production-grade out of the box. You need a reverse proxy with TLS, secrets management, WAL archiving for backups, and monitoring for each service independently.
RLS policy performance: RLS policies execute on every row during query evaluation. A policy that calls auth.uid() (which reads current_setting(...)) is cheap. A policy that does a subquery joins or calls a function with a SECURITY DEFINER context is expensive at scale. Check EXPLAIN ANALYZE output with SET row_security = on to confirm policies are not causing sequential scans. Index your RLS predicate columns.
Schema migrations: PostgREST reloads its schema cache on NOTIFY pgrst, 'reload schema'. The Supabase CLI handles this automatically during db push. In CI/CD, run migrations against the database first, then trigger the reload. Never apply migrations that break existing API contracts (dropping columns, changing types) without a multi-step rollout: add the new column, deploy code that reads both, drop the old column.
Realtime scalability: At high fan-out (one insert notifying thousands of clients), the Realtime server becomes the bottleneck. The Realtime cluster on the managed platform scales horizontally, but on self-hosted deployments you manage that yourself. For extremely high write throughput with many subscribers, consider whether a dedicated message broker is more appropriate than Realtime Postgres Changes.
Edge Functions cold starts: Deno Deploy on the managed platform has cold start latency for infrequently called functions. For latency-sensitive paths, keep functions warm with a scheduled ping or move the logic into a PostgreSQL function if it can be expressed in SQL or PL/pgSQL.
Tradeoffs Comparison
| Dimension | Supabase | Firebase | Custom PostgreSQL stack | Convex | Neon |
|---|---|---|---|---|---|
| Data model | Relational (PostgreSQL) | Document (Firestore) / RTDB | Relational (PostgreSQL) | Document + reactive queries | Relational (PostgreSQL, serverless) |
| Auth | GoTrue (built-in, JWT + RLS) | Firebase Auth (built-in, SDK-based rules) | DIY or third-party (Auth0, Clerk) | Built-in (Convex Auth) | None (bring your own) |
| Real-time | WAL logical replication via WebSocket | Firestore listeners / RTDB streaming | DIY (polling, SSE, or separate WS server) | Reactive function subscriptions | None (bring your own) |
| API layer | PostgREST auto-generated REST | Firestore SDK / REST | DIY (REST, GraphQL, tRPC) | TypeScript functions (type-safe, end-to-end) | Direct SQL or bring your own ORM |
| Access control | PostgreSQL RLS (SQL predicates) | Firebase Security Rules (custom DSL) | DIY at application layer | Convex auth helpers in functions | DIY |
| Vendor lock-in | Low (standard PostgreSQL; open source) | High (proprietary SDK and data model) | None (you own everything) | Medium (Convex runtime and data model) | Low (standard PostgreSQL; Neon-specific branching API) |
| Self-hostable | Yes (complex) | No | Yes (you build it) | No | Yes (open source, with limitations) |
| Operational overhead | Medium (managed) / High (self-hosted) | Low | High | Low | Low |
| SQL queries | Full PostgreSQL SQL | No | Full SQL | No (TypeScript functions only) | Full PostgreSQL SQL |
| Sweet spot | Relational data with complex access control, real-time, and auth needed fast | Mobile/web apps that can work with a document model and accept Google lock-in | Teams with strong backend expertise who need maximum control | TypeScript-first teams wanting end-to-end type safety and reactive UX with no SQL | Teams needing serverless PostgreSQL with branching for dev/test workflows |
When Supabase Fits Well
Supabase works best when your data is naturally relational, your access control is row-level (not just table-level), and you want to avoid maintaining a separate API server just to proxy database queries. If you already know SQL well, the PostgREST model removes an entire layer of boilerplate without hiding the database from you.
It fits badly when your schema changes frequently in ways that break the PostgREST contract, when you need complex multi-step transactional business logic that cannot be expressed as SQL functions, or when your team has no PostgreSQL operational experience and you need to self-host for compliance reasons.
The architecture’s key property is also its key constraint: because PostgreSQL is the source of truth for everything including the API shape, the authorization model, the real-time event stream, and the storage metadata, the database becomes a coordination point for all six services. Understanding that dependency graph is what separates teams that operate Supabase successfully at scale from teams that hit unexpected failure modes when one part of the platform degrades.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.