← All posts
·6 min read·GuardLayer

Fix 42501: must be owner of table objects

SupabaseStorageRLSMigrations

ERROR: 42501: must be owner of table objects means your migration contains ALTER TABLE storage.objects ..., which Supabase blocked in April 2025 — even for the project owner. RLS is already enabled on storage.objects by default, so delete that line and keep only your CREATE POLICY statements.

This one is confusing because you are the owner. You created the project, you're logged in as the owner, the dashboard says Owner. The error is about Postgres table ownership, which is a different thing entirely: storage.objects is owned by supabase_storage_admin, not by your postgres role, and ALTER TABLE requires ownership.

What changed, and when

On April 21, 2025 Supabase restricted SQL operations on the auth, storage and realtime schemas to stop projects from breaking their own platform services. After that change the postgres role can no longer:

  • create or drop tables and functions in those schemas
  • create indexes on their existing tables
  • write to their migration tables
  • revoke privileges on those tables from the API roles

The same permission tightening is what removed your ability to ALTER those tables — storage.objects is owned by supabase_storage_admin, and your postgres role isn't it. ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY used to be in every storage tutorial, which is why it's still sitting in migration files across thousands of projects — and why AI code generators still emit it, since they learned from that same corpus.

What you can still do is the part you actually need. The announcement is explicit that creating RLS policies and triggers on storage.objects remains permitted. The block is on altering the table, not on securing it.

Why the statement was never necessary

Supabase enables RLS on storage.objects by default. That's the maintainer's answer on the canonical issue:

Row level security is now enabled by default on storage.objects table, so you can safely delete or comment out the offending statement from your migration file.

So the line you're fighting was a no-op even before it became an error.

How do I fix "must be owner of table objects"?

Delete the ALTER TABLE line and keep the policies. A storage migration that applies cleanly today:

-- supabase/migrations/20260817120000_avatars_storage.sql

-- Private bucket. Access is decided entirely by the policies below.
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', false)
on conflict (id) do nothing;

-- NOTE: no ALTER TABLE storage.objects here. RLS is already on,
-- and altering the table is blocked since April 2025.

create policy "users read their own avatar"
  on storage.objects for select
  to authenticated
  using (
    bucket_id = 'avatars'
    and owner_id = (select auth.uid()::text)
  );

create policy "users upload to their own folder"
  on storage.objects for insert
  to authenticated
  with check (
    bucket_id = 'avatars'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

create policy "users delete their own avatar"
  on storage.objects for delete
  to authenticated
  using (
    bucket_id = 'avatars'
    and owner_id = (select auth.uid()::text)
  );

Two details worth copying deliberately:

  • Every policy pins bucket_id. A policy on storage.objects without it applies to every bucket in the project, because all objects live in that one table. That's the most common way a storage policy grants far more than intended.
  • (storage.foldername(name))[1] = auth.uid()::text is the per-user folder idiom. Objects are addressed as <uid>/filename, so this scopes writes to the caller's own prefix. The ::text cast matters — comparing a uuid to a text column silently evaluates false rather than erroring.
  • Use owner_id, not owner. Supabase's docs mark owner as deprecated and slated for removal. Note that owner_id is typed text, not uuid, so comparing it to a bare auth.uid() fails outright with operator does not exist: text = uuid — hence the ::text cast above. Both fields are only populated for objects uploaded through an authenticated client; anything written with the service role or via the dashboard leaves them unset, so a policy keyed on ownership will hide those objects from everyone. If your uploads happen server-side, scope on the folder prefix instead.

If the bad migration is already in remote history, editing the file isn't enough — the CLI will still try to reconcile it. Mark it applied and move on:

supabase migration repair --status applied <version>

Then run supabase db pull to confirm local and remote history agree.

The shortcut that publishes every file you own

When policies can't be written the way a tutorial says, the fast way out is to stop needing them:

-- Couldn't ALTER storage.objects, so the bucket was made public instead.
update storage.buckets set public = true where id = 'avatars';
guardlayer scan · supabase/migrations/20260817120000_avatars_storage.sqlLive engine output
Check failed
75/100 · B
  • Criticalsupabase/migrations/20260817120000_avatars_storage.sql:2

    Public storage bucket

    Set public: false and serve files through signed URLs (createSignedUrl) or RLS-protected access. Only keep a bucket public for genuinely public assets.

Uploads start working immediately, which is the whole problem. Marking a bucket public means reads through the public URL bypass authentication and RLS entirely/object/public/<bucket>/<path> serves the file to anyone, with no policy evaluated on that path. (Listing and other operations still go through policies; it's the download path that opens up.) Object URLs are built from the bucket id and the object path, so a bucket organised as <uid>/avatar.png is fetchable by anyone who can guess or enumerate user ids — which means anything a user uploads privately, from invoices to ID scans, becomes readable without a login. Nothing about the app looks different from the inside.

Keep the bucket private and hand out time-limited signed URLs for anything a user should see. public: true is for assets you'd happily put on a CDN — logos, marketing images — and nothing else. GuardLayer flags public buckets in both migrations and JS, because this shortcut is invisible in code review once the migration has merged.

Quick self-check

# Any blocked ALTERs still in your migrations?
grep -rn "ALTER TABLE storage\.\|ALTER TABLE auth\.\|ALTER TABLE realtime\." supabase/migrations/

And from SQL, the two questions that matter:

-- Which buckets are public?
select id, public from storage.buckets order by public desc;

-- What does each storage policy actually allow?
select policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'storage' and tablename = 'objects';

Any policy whose predicate mentions neither bucket_id nor auth.uid() is doing less work than you think. If your uploads are failing rather than over-sharing, the sibling error is new row violates row-level security policy on storage.objects, which means the INSERT policy is missing rather than blocked.

FAQ

Why do I get "must be owner of table objects" when I own the project? Project ownership isn't Postgres table ownership. storage.objects is owned by supabase_storage_admin, and ALTER TABLE requires the owner role. Supabase restricted the storage schema in April 2025, so your postgres role can no longer alter it.

Do I need to enable RLS on storage.objects? No. Supabase enables it by default. The ALTER TABLE ... ENABLE ROW LEVEL SECURITY line was redundant before it became an error.

Can I still create storage policies? Yes. Creating RLS policies and triggers on storage.objects is explicitly still permitted — only altering the table is blocked. Use plain CREATE POLICY statements or the dashboard's storage policy editor.

Will contacting support give me ownership of storage.objects? No, and you don't want it. The restriction protects the storage service from being broken by a migration. Everything you legitimately need — buckets and policies — works without table ownership.

My migration already ran on remote and now the CLI is stuck. Remove the offending statement from the file, then run supabase migration repair --status applied <version> so local and remote history agree.

Is making the bucket public a reasonable workaround? No. Reads through a public bucket's URL bypass authentication and RLS, and object paths are predictable. Keep the bucket private and use signed URLs.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.