From cc4d985c401a37bd8bb3f7fdba4043c65d0ffdc7 Mon Sep 17 00:00:00 2001
From: Inder Singh <85822513+singh-inder@users.noreply.github.com>
Date: Wed, 22 Apr 2026 14:54:58 +0530
Subject: [PATCH] feat(docker): remove superuser access (#42975)
---
.../NavigationMenu.constants.ts | 1 +
.../self-hosting/remove-superuser-access.mdx | 75 +++++++++
docker/docker-compose.yml | 4 +
docker/utils/reassign-owner.sh | 153 ++++++++++++++++++
4 files changed, 233 insertions(+)
create mode 100644 apps/docs/content/guides/self-hosting/remove-superuser-access.mdx
create mode 100644 docker/utils/reassign-owner.sh
diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
index 3e9a854c652..776fcec5926 100644
--- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
+++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
@@ -2897,6 +2897,7 @@ export const self_hosting: NavMenuConstant = {
{ name: 'Configure Phone Login & MFA', url: '/guides/self-hosting/self-hosted-phone-mfa' },
{ name: 'Configure SAML 2.0 SSO', url: '/guides/self-hosting/self-hosted-saml-sso' },
{ name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' },
+ { name: 'Remove superuser access', url: '/guides/self-hosting/remove-superuser-access' },
],
},
{
diff --git a/apps/docs/content/guides/self-hosting/remove-superuser-access.mdx b/apps/docs/content/guides/self-hosting/remove-superuser-access.mdx
new file mode 100644
index 00000000000..a2b7c6d6d8d
--- /dev/null
+++ b/apps/docs/content/guides/self-hosting/remove-superuser-access.mdx
@@ -0,0 +1,75 @@
+---
+title: 'Remove superuser access from Studio'
+description: 'Learn how to switch from the supabase_admin to postgres role in self-hosted Supabase.'
+subtitle: 'Learn how to switch from the supabase_admin to postgres role in self-hosted Supabase.'
+---
+
+## Overview
+
+In late 2022, Supabase introduced a security change in hosted projects that removed superuser access from the dashboard SQL editor and shifted ownership of user-created database objects away from `supabase_admin` toward the `postgres` role.
+You can read more about it in the [official announcement](https://github.com/orgs/supabase/discussions/9314).
+
+However, this migration was never automatically applied to self-hosted Supabase instances.
+
+As a result:
+
+- Objects created via the dashboard may still be owned by `supabase_admin`
+- Behavior differs from the Supabase platform
+- Some migrations may fail when run as `postgres`
+
+This guide explains how to align your self-hosted Supabase instance with the security enhancements and ownership model used on the Supabase platform.
+
+## Changing the configuration
+
+### Step 1: Update database object ownership
+
+Use the provided script to reassign ownership of database objects in the `public` schema from `supabase_admin` to `postgres`. From the project directory containing `docker-compose.yml`, run:
+
+```sh
+sh utils/reassign-owner.sh
+```
+
+
+
+This script only updates ownership for database objects in the `public` schema. Supabase-managed and custom schemas are not affected.
+
+
+
+### Step 2: Update environment variables in docker-compose.yml
+
+- In your `docker-compose.yml` configuration, uncomment the following line for the `studio` service to use the `postgres` role for read/write operations:
+
+ ```yml name=docker-compose.yml
+ studio:
+ environment:
+ POSTGRES_USER_READ_WRITE: postgres
+ ```
+
+- Locate the `meta` service environment variables and change the `PG_META_DB_USER` environment variable from `supabase_admin` to `postgres`:
+
+ ```yml name=docker-compose.yml
+ meta:
+ environment:
+ PG_META_DB_USER: postgres
+ ```
+
+
+
+Studio uses its own credentials to access Postgres via `postgres-meta`, so this change is only needed for backward compatibility and consistency.
+
+
+
+### Step 3: Restart Supabase
+
+```sh
+docker compose down && docker compose up -d
+```
+
+## Verify roles
+
+After restarting your services, verify that Supabase Studio is now using the `postgres` role. Run the following query in the Supabase Studio SQL Editor:
+
+```sql
+select current_user;
+-- expected result: postgres
+```
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index aa9e9cd0e6c..7bcf1a1c47d 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -37,6 +37,10 @@ services:
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+
+ # See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access
+ #POSTGRES_USER_READ_WRITE: postgres
+
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
diff --git a/docker/utils/reassign-owner.sh b/docker/utils/reassign-owner.sh
new file mode 100644
index 00000000000..d13b1707337
--- /dev/null
+++ b/docker/utils/reassign-owner.sh
@@ -0,0 +1,153 @@
+#!/bin/sh
+#
+# Reassign ownership of public schema objects from supabase_admin to postgres.
+#
+# Context and documentation:
+# https://supabase.com/docs/guides/self-hosting/remove-superuser-access
+#
+# Credits:
+# Original version by Inder Singh.
+#
+# Usage:
+# sh utils/reassign-owner.sh
+#
+
+set -e
+
+if ! docker compose version >/dev/null 2>&1; then
+ echo "Docker Compose not found."
+ exit 1
+fi
+
+# Check Postgres service
+db_image_prefix="supabase.postgres:"
+
+compose_output=$(docker compose ps \
+ --format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null |
+ grep -m1 "^$db_image_prefix" || true)
+
+if [ -z "$compose_output" ]; then
+ echo "Postgres container not found. Exiting."
+ exit 1
+fi
+
+db_srv_name=$(echo "$compose_output" | cut -f2)
+db_srv_status=$(echo "$compose_output" | cut -f3)
+
+case "$db_srv_status" in
+ Up*)
+ ;;
+ *)
+ echo "Postgres container status: $db_srv_status"
+ echo "Exiting."
+ exit 1
+ ;;
+esac
+
+if ! test -t 0; then
+ echo ""
+ echo "Running non-interactively. Not reassigning ownership."
+ exit 0
+fi
+
+printf "Reassign public schema objects to postgres user? (y/N) "
+read -r REPLY
+case "$REPLY" in
+ [Yy])
+ ;;
+ *)
+ echo "Canceled. Not reassigning ownership."
+ exit 0
+ ;;
+esac
+
+docker compose exec -T "$db_srv_name" psql -v ON_ERROR_STOP=1 -U supabase_admin -d postgres <<'EOF'
+\echo 'Current supabase_admin-owned objects in public schema:'
+SELECT c.relname, c.relkind, c.relowner::regrole
+FROM pg_class c
+WHERE c.relnamespace = 'public'::regnamespace
+AND c.relowner = 'supabase_admin'::regrole;
+
+-- Reassign user objects in public schema from supabase_admin to postgres.
+-- (Only affects public schema; Supabase-managed schemas stay as-is.
+-- Extension-owned objects are skipped.)
+DO $$
+DECLARE
+ rec record;
+ rel_count int := 0;
+ fn_count int := 0;
+ type_count int := 0;
+BEGIN
+ -- Tables, views, sequences, materialized views, partitioned tables
+ FOR rec IN
+ SELECT c.relname, c.relkind
+ FROM pg_class c
+ WHERE c.relnamespace = 'public'::regnamespace
+ AND c.relowner = 'supabase_admin'::regrole
+ AND c.relkind IN ('r', 'v', 'S', 'm', 'p')
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_depend d
+ WHERE d.classid = 'pg_class'::regclass
+ AND d.objid = c.oid
+ AND d.deptype = 'e'
+ )
+ ORDER BY CASE c.relkind
+ WHEN 'p' THEN 0 -- partitioned parents first; cascades ownership to partitions
+ WHEN 'm' THEN 1
+ WHEN 'r' THEN 2
+ WHEN 'v' THEN 3
+ WHEN 'S' THEN 4
+ END
+ LOOP
+ EXECUTE format('ALTER TABLE public.%I OWNER TO postgres', rec.relname);
+ rel_count := rel_count + 1;
+ END LOOP;
+
+ -- Functions and procedures
+ FOR rec IN
+ SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args
+ FROM pg_proc p
+ WHERE p.pronamespace = 'public'::regnamespace
+ AND p.proowner = 'supabase_admin'::regrole
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_depend d
+ WHERE d.classid = 'pg_proc'::regclass
+ AND d.objid = p.oid
+ AND d.deptype = 'e'
+ )
+ LOOP
+ EXECUTE format('ALTER ROUTINE public.%I(%s) OWNER TO postgres', rec.proname, rec.args);
+ fn_count := fn_count + 1;
+ END LOOP;
+
+ -- Types (excluding array types and table-bound composites)
+ FOR rec IN
+ SELECT t.typname
+ FROM pg_type t
+ WHERE t.typnamespace = 'public'::regnamespace
+ AND t.typowner = 'supabase_admin'::regrole
+ AND t.typrelid = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_type el
+ WHERE el.oid = t.typelem
+ AND el.typarray = t.oid
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_depend d
+ WHERE d.classid = 'pg_type'::regclass
+ AND d.objid = t.oid
+ AND d.deptype = 'e'
+ )
+ LOOP
+ EXECUTE format('ALTER TYPE public.%I OWNER TO postgres', rec.typname);
+ type_count := type_count + 1;
+ END LOOP;
+
+ RAISE NOTICE 'Reassigned % relation(s), % routine(s), % type(s) from supabase_admin to postgres.',
+ rel_count, fn_count, type_count;
+END
+$$;
+EOF
+
+echo ""
+echo "Done."