Over-open rules
Anyone with the anon key can join a Supabase Realtime room
Cataloged October 20, 2026 · Lovable · Bolt · Cursor · Replit · v0 · Supabase
Symptom
Chat, game moves, or live presence go out over a Supabase Realtime channel. The app checks who is signed in, but the channel itself is public, so anyone holding the project's anon key can listen in and send fake messages on that topic.
Root cause
Supabase Realtime has a project setting called Allow public access to channels, and its default is Enabled. The Realtime Settings page says that while it is enabled, no policy check runs, and anyone holding your project's anon key can subscribe to and broadcast on any public channel. The anon key ships in the browser bundle, so a sign-in screen in front of a room protects nothing. A channel is only checked when the client creates it with config: { private: true } and the database has Row Level Security policies on realtime.messages. The Realtime Authorization guide says those policies control who can send and receive Broadcast and Presence on a topic, and that to enforce private channels you need to disable Allow public access. With the setting disabled, clients that do not set private to true are rejected with PrivateOnly, and with no policies, clients connect but receive no messages. Postgres Changes is different: records from tables with RLS are sent only to clients allowed to read them. KovalDenys1/Boardly issue 801, opened September 3, 2026, reports that lobbies subscribed to lobby:{code} without private: true, using the public anon key, with 4-digit codes. Chat history and posting were gated on membership over the REST API, but each message was then broadcast to the same open topic, and a user without a username had their email address published there. Pull request 844, merged September 4, 2026, cut the broadcast down to an id and timestamp and removed the email fallback, and its description says the open topic itself still needed a follow-up. chenjsfrost/league-of-ten issue 1, opened October 1, 2026, reports that rooms ran over public channels, so a script could join any room without logging in and then send broadcasts and track presence. The fix commit that day moved rooms to private: true with policies on realtime.messages. A follow-up commit then gated private channels behind a flag that was turned off, because the policies could not be created, every private join was refused, and the game showed Could not connect. The next commit removed an ALTER TABLE on realtime.messages from the migration. The Authorization guide warns about exactly this: RLS is already enabled on realtime.messages, and that statement fails with must be owner of table messages and skips the create policy statements after it. sidherun/sidherun-character-builder issue 332, opened September 19, 2026, reports roll and character channels created without private: true and migrations with no Realtime Authorization policies. The owner's review comment says renaming the topic or hashing an id on the client cannot close it, because the project URL and anon key are public.
Fix prompt
In this project and its own Supabase project only. Do not test against any other app or project.
1. List every Realtime channel. Search the code for supabase.channel( and for realtime.send, and note each topic, whether it uses broadcast or presence, and whether it passes config: { private: true }. Postgres Changes on tables with RLS already respects those table policies. This fix is for Broadcast and Presence.
2. Check the current policies in the SQL editor:
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'realtime' and tablename = 'messages';
3. Decide who may join each topic, using a membership table the app already has (for example room_members or lobby_players). If there is none, create one:
create table if not exists public.room_members (
user_id uuid not null references auth.users on delete cascade,
topic text not null,
primary key (user_id, topic)
);
alter table public.room_members enable row level security;
create policy "members read own memberships"
on public.room_members for select to authenticated
using ((select auth.uid()) = user_id);
Do not let the browser insert arbitrary membership rows. Add members from server code or from a join function that checks the room.
4. Add policies on realtime.messages. Do not add alter table realtime.messages enable row level security. RLS is already on, and that line fails with must be owner of table messages and skips every statement after it.
create policy "members can receive on their topic"
on realtime.messages for select to authenticated
using (
exists (
select 1 from public.room_members m
where m.user_id = (select auth.uid())
and m.topic = (select realtime.topic())
and realtime.messages.extension in ('broadcast', 'presence')
)
);
create policy "members can send on their topic"
on realtime.messages for insert to authenticated
with check (
exists (
select 1 from public.room_members m
where m.user_id = (select auth.uid())
and m.topic = (select realtime.topic())
and realtime.messages.extension in ('broadcast', 'presence')
)
);
Match the topic format to what the app really uses.
5. In the client, send the session to Realtime and make every Broadcast or Presence channel private:
await supabase.realtime.setAuth();
const channel = supabase.channel('room:' + roomId, { config: { private: true } });
Database broadcasts from realtime.send must use private => true, and Broadcast REST calls must add ?private=true, or private channels will not receive them.
6. Stop putting anything in a payload that the API keeps private, such as email addresses or gated chat text. Do not trust a user id, name, or role written inside a payload. Take identity from the signed-in session or from server code.
7. When every channel from step 1 is private and tested, turn off Allow public access to channels in Realtime Settings. This disconnects all connected clients, and any channel still missing private: true is rejected with PrivateOnly.
8. Access is cached for the connection, so a removed member keeps receiving messages until their JWT expires or a new one is sent. Keep the JWT expiry short. A long random room code is good practice, but it is not access control.
Do not print tokens, keys, or user rows. Do not edit unrelated files.Paste this into the coding tool that has your own project open.
2-minute self-check
These steps are for an app you own. Do not run them against someone else's project.
- Open Realtime Settings in your own Supabase dashboard. If Allow public access to channels is enabled, the settings page says anyone holding your anon key can subscribe to and broadcast on any public channel.
- Search your own code for supabase.channel( and check each Broadcast or Presence channel for config: { private: true }. Any channel without it is public while that setting is enabled.
- In your own SQL editor, run select policyname, cmd from pg_policies where schemaname = 'realtime' and tablename = 'messages'. No rows means private channels would receive nothing, and a policy that never checks realtime.topic() and membership lets any signed-in user into every topic.
Common questions
My app makes people sign in before they can open a room. How can someone without an account still read the room's messages?
The sign-in screen guards the page, not the Realtime channel. Supabase's Realtime Settings page says Allow public access to channels is enabled by default, and while it is enabled no policy check runs and anyone holding your project's anon key can subscribe to and broadcast on any public channel. The anon key ships in the browser bundle. KovalDenys1/Boardly issue 801 reports chat that was gated on membership over the REST API and then broadcast to an open lobby topic, along with some users' email addresses.
I set private: true and now nobody can connect. What went wrong?
A private channel needs Row Level Security policies on realtime.messages. With public access disabled and no policies, clients connect but receive no messages. In chenjsfrost/league-of-ten, every private join was refused because the policies could not be created, and the migration included an ALTER TABLE on realtime.messages. Supabase's Realtime Authorization guide says RLS is already enabled on that table, and that statement fails with must be owner of table messages and skips the create policy statements after it. Remove that line, create the select and insert policies, and call supabase.realtime.setAuth() before subscribing.
Sources
- Supabase: Realtime Authorization
- Supabase: Realtime Settings (Allow public access to channels)
- Supabase: Broadcast (setAuth and private database broadcasts)
- KovalDenys1/Boardly issue 801 (September 3, 2026)
- KovalDenys1/Boardly pull request 844 (September 4, 2026)
- chenjsfrost/league-of-ten issue 1 (October 1, 2026)
- chenjsfrost/league-of-ten commit 084ffc6 (fall back to public channels)
- chenjsfrost/league-of-ten commit c1e6a71 (drop ALTER TABLE on realtime.messages)
- sidherun/sidherun-character-builder issue 332 (September 19, 2026)