docs(realtime): realtime permissions (#50735)

All changes are related to Realtime permissions that I observed on
support tickets recently:

- Migrations can't have `ALTER TABLE realtime.messages`. That's is not
allowed and breaks migrations.
- Missing realtime.messages partitions are usually due to lack of
connections
- Errors like "must be owner of table messages" are misleading

Closes REAL-1125

## Additional context

https://supabase.slack.com/archives/C01G8CC0X9D/p1789981286693829 and
SU-479680

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which references
[WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md)
and the docs
[CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md)
guide


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Clarified Realtime authorization restrictions, supported RLS policy
management, and the existing RLS configuration for `realtime.messages`.
* Documented broadcast partition creation, connection requirements, and
warning behavior when partitions are unavailable.
* Added troubleshooting guidance for ownership errors, including
migration rollback implications and supported remedies.
* Added instructions for inspecting message partitions and diagnosing
missing, expired, or unavailable partition configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Leandro Pereira authored and GitHub committed 2026-09-25 13:39:51 -04:00
1 parent a21111b7e0
commit c6b92060d2
4 files changed
+114 -2

No files matched your search

@@ -25,9 +25,17 @@ By creating RLS policies on the `realtime.messages` table you can control the ac
<Admonition type="caution">
Realtime locks down the `realtime` schema to protect it against unexpected changes to guarantee the healthy operation of the Realtime service and avoid conflicts that could be caused by future migrations. Creating a table or function in `realtime` is expected to fail with `permission denied for schema realtime`, whether you run the SQL yourself or through the dashboard. Managing RLS policies on `realtime.messages` is allowed.
Realtime locks down the `realtime` schema to protect it against unexpected changes to guarantee the healthy operation of the Realtime service and avoid conflicts that could be caused by future migrations. Creating a table or function in `realtime` is expected to fail with `permission denied for schema realtime`, whether you run the SQL yourself or through the dashboard. Managing RLS policies on `realtime.messages` is allowed: [`supautils`](https://github.com/supabase/supautils) lets the `postgres` role run policy statements on that table without owning it.
Row level security (RLS) is enabled by default on table `realtime.messages`, you don't need to execute `ALTER TABLE realtime.messages ENABLE ROW LEVEL SECURITY`.
Row Level Security is already enabled on `realtime.messages`, so don't add `ALTER TABLE realtime.messages ENABLE ROW LEVEL SECURITY` to a migration. Postgres checks table ownership before it checks whether the setting would change, so the statement fails with `42501 must be owner of table messages`. That aborts the transaction, and every statement after it is skipped, including any `create policy` statements that follow.
Remove the `ALTER TABLE` line and the rest of the migration passes. If a migration needs to check that RLS is on, read the catalog:
```sql
select relrowsecurity from pg_class where oid = 'realtime.messages'::regclass;
```
If you hit that error, see [must be owner of table messages](/docs/guides/troubleshooting/realtime-must-be-owner-of-table-messages).
</Admonition>
@@ -1020,6 +1020,8 @@ Broadcast Changes allows you to trigger messages from your database. To achieve
It uses partitioned tables per day, which allows performant deletion of your previous messages by dropping the physical tables of this partitioned table. Tables older than 3 days are deleted.
A client connecting over WebSocket creates those daily partitions. `realtime.send` does not. With no partition for today, the insert fails and Postgres logs a `WarnSendingBroadcastMessage` warning, so connect a client before you broadcast from the database. See [Realtime: `WarnSendingBroadcastMessage`](/docs/guides/troubleshooting/realtime-warn-sending-broadcast-message) if you hit that warning.
Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON payloads. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data.
Broadcast Changes provides two functions to help you send messages:
@@ -0,0 +1,84 @@
---
title = "Realtime: \"must be owner of table messages\" when setting up Authorization"
topics = [ "realtime" ]
keywords = [ "realtime.messages", "must be owner", "42501", "row level security", "policy", "authorization", "broadcast", "migration" ]
[[errors]]
code = "42501"
message = "must be owner of table messages"
---
A migration that adds an RLS policy to `realtime.messages` fails:
```
ERROR: 42501: must be owner of table messages
```
## Cause
`realtime.messages` is owned by an internal role, `supabase_realtime_admin`. The `postgres` role is not a member of it and is not a superuser.
Policies still work because of [`supautils`](https://github.com/supabase/supautils), the extension that gives the `postgres` role its elevated permissions on a Supabase project. It lets `postgres` run `create policy`, `alter policy`, and `drop policy` on a fixed list of tables it does not own, and `realtime.messages` is on that list. The list covers policy statements only, so `ALTER TABLE` is still refused.
The failing statement is usually not the `create policy`. It's an `ALTER TABLE` earlier in the same migration:
```sql
alter table realtime.messages enable row level security;
alter table realtime.messages add column ...;
alter table realtime.messages owner to postgres;
```
The first one is the common case. RLS is already enabled on `realtime.messages`, and Postgres checks ownership before it checks whether the setting would change, so the statement fails on every project.
A failed statement aborts the transaction, so Postgres skips everything after it, including the `create policy`. `supabase db push` runs each migration file in one transaction, so the file rolls back and nothing is written to `supabase_migrations.schema_migrations`.
## Fix
Remove the `ALTER TABLE` line. Keep the policy:
```sql
create policy "authenticated can read messages on room-1"
on "realtime"."messages"
for select
to authenticated
using (
(select realtime.topic()) = 'room-1'
);
```
Run it from the SQL editor or with `supabase db push`. Both connect as `postgres`.
To check that RLS is on, read the catalog instead of setting it:
```sql
select relrowsecurity from pg_class where oid = 'realtime.messages'::regclass;
```
## What doesn't work
Granting the owning role fails, and support can't grant it either:
```sql
grant supabase_realtime_admin to postgres;
-- ERROR: 42501: "supabase_realtime_admin" role memberships are reserved, only superusers can grant them
```
Restarting the project changes nothing.
Wrapping the `ALTER TABLE` in a function or a `do` block changes nothing either. The ownership check still runs, and a `security definer` function owned by `postgres` is still not the table owner.
## What postgres can do on realtime.messages
The `Yes` rows are what `supautils` delegates, plus the privileges granted to `postgres` directly.
| Statement | Allowed |
| ----------------------------------------------------- | ------- |
| `create policy`, `alter policy`, `drop policy` | Yes |
| `comment on policy` | Yes |
| `select`, `insert` | Yes |
| `grant select`, `grant insert` to your own roles | Yes |
| `alter table` in any form | No |
| `drop table` | No |
| Creating tables or functions in the `realtime` schema | No |
See [Realtime Authorization](/docs/guides/realtime/authorization) for writing the policies.
@@ -45,6 +45,24 @@ Calling `realtime.send` or `realtime.send_binary`, the broadcast REST endpoint,
</Admonition>
## Check which partitions you have
```sql
select c.relname
from pg_inherits i
join pg_class c on c.oid = i.inhrelid
where i.inhparent = 'realtime.messages'::regclass
order by c.relname;
```
The names are dates, so the result tells you which case you are in:
- One covers today. The missing partition is not the cause, so go to [When it is a real problem](#when-it-is-a-real-problem).
- They all end on a past date. Your window lapsed. The newest name is three days after the last time partitions were created, because each creation covers yesterday through three days ahead.
- No rows. No client has ever connected, so no partition was ever created.
Zero rows does not mean the Realtime migrations are missing. Those would fail this query with `42P01 relation "realtime.messages" does not exist`. If you see `42P01`, open a support ticket.
## How to avoid it
- Connect a client before broadcasting from the database. A live WebSocket connection both creates the partitions and starts the consumer that receives the message.