Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Supabase Realtime shows other users' data: how to fix it

FIX / REALTIME DATA LEAK / 9 MIN READ

Supabase Realtime shows other users’ data when the table has no working RLS policy or when the channel is public, so turn on RLS, write policies that use auth.uid(), and make the channel private. Realtime does not add security of its own. It follows the same rules as the rest of your database. A filter in the client code is not a rule.

This guide shows the four causes, how to test them with two accounts, and how to fix each cause. It also shows how to check what was exposed.

Why does Supabase Realtime show other users’ data?

Realtime shows other users’ data because the database allows these users to read the data. There are four common causes.

  1. RLS is off on the table. A table without RLS is open to the public key. Postgres Changes sends every change on that table to every subscriber.
  2. A policy is too wide. A policy such as using (true) lets every user read every row. Realtime then sends every row.
  3. The channel is public. A Broadcast or Presence channel without the private flag has no authorization. The Realtime authorization documentation states that Realtime checks the policies on realtime.messages only for private channels.
  4. A DELETE event carries data. RLS policies do not apply to DELETE events. The Postgres Changes documentation explains the reason: “there is no way for Postgres to verify that a user has access to a deleted record.”

Vibe coding tools often cause causes 1 and 2. You ask for a live chat or a live dashboard. The tool turns on Realtime for the table and adds a policy that makes the feature work. The easiest policy to write is the open one.

The filter is a separate source of confusion. Code like this looks safe:

supabase
  .channel("my-orders")
  .on("postgres_changes",
    { event: "*", schema: "public", table: "orders", filter: `user_id=eq.${userId}` },
    (payload) => setOrders((o) => [...o, payload.new]))
  .subscribe();

The filter runs on the server, but the client sends it. A user can open the browser console and send a different value for user_id. If the policy allows the read, the server sends the rows. The filter is a tool to save traffic. It is not an access control.

How do I confirm that Realtime leaks data?

Test with two real accounts. Do not trust the code review. You need user A, user B, and a table that holds rows for each user.

  1. Create two test users in your app. Call them A and B.
  2. Sign in as user B in a script. Subscribe to the table with no filter.
  3. Create or change a row as user A in the app.
  4. Watch the output of the script. If user B receives the row of user A, the leak is confirmed.

This script runs with Node.js and the @supabase/supabase-js package:

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);

await supabase.auth.signInWithPassword({
  email: "user-b@example.com",
  password: process.env.USER_B_PASSWORD!,
});

supabase
  .channel("leak-test")
  .on("postgres_changes", { event: "*", schema: "public", table: "orders" },
    (payload) => console.log("RECEIVED", payload.eventType, payload.new, payload.old))
  .subscribe((status) => console.log("status", status));

Replace orders with your table. Keep the process open and make a change as user A. The script must print nothing for rows that belong to user A.

Repeat the test without any sign-in. Remove the signInWithPassword call and use only the public key. If the events arrive, any visitor of your site can read the data. This is the worst case. Read what the Supabase anon key can do to see why the public key is not a secret.

Test a Broadcast or Presence channel

  1. Find the channel names in your code. Search for .channel(.
  2. Subscribe to the same name with only the public key and no sign-in.
  3. Make the app send a message and check the output.
supabase
  .channel("room-1")
  .on("broadcast", { event: "*" }, (msg) => console.log("RECEIVED", msg))
  .subscribe((status) => console.log("status", status));

The Broadcast documentation says that a public channel lets “anyone subscribe to that topic without authentication.” If your script receives the messages, the channel is public. Topic names such as room-1 or user-42 are easy to guess. Treat a public channel as readable by all.

Check the DELETE case

Delete a row as user A while the script of user B runs. Check if user B gets a DELETE event. The event can contain the key of the row. If the table uses replica identity full, the old values can also reach the subscriber. See the Postgres Changes documentation for this setting.

How do I fix the leak now?

Do the steps in this order. Steps 1 and 2 stop the leak for the most common cause.

Caution: Turning on RLS without policies blocks all reads through the API. Your live feature stops until you add the policy. Add the policies in the same transaction, or use a short maintenance window.

  1. Turn on RLS on the table. Run alter table public.orders enable row level security; in the SQL editor.
  2. Delete every open policy. Search for using (true) and for policies that grant select to anon for private data.
  3. Add a policy that matches the owner. Each user must read only their own rows.
  4. Make Broadcast and Presence channels private. Set private: true in the client and add policies on realtime.messages.
  5. Turn off Allow public access. This is in the Realtime settings of the project.
  6. Change the DELETE flow. Use a soft delete, or do not keep private data in the key of the row.
  7. Test again with two users. Use the script from the confirm section.

This is the policy for step 3:

alter table public.orders enable row level security;

create policy "users read own orders" on public.orders
  for select to authenticated
  using (user_id = (select auth.uid()));

The select auth.uid() form is the form that the Supabase documentation uses in its own examples. Add insert, update, and delete policies with the same check. For the full method, read the Supabase RLS guide. This article gives only the short version.

How do I make a channel private?

Set the private option in the client. The Realtime authorization documentation gives this example:

const channel = supabase.channel("room-1", {
  config: { private: true },
});

A private channel needs a policy on realtime.messages. Without a policy, nobody can join. The documentation gives an example that uses a membership table. This is a version for a table called rooms_users:

create policy "members receive broadcast and presence"
on "realtime"."messages"
for select
to authenticated
using (
  exists (
    select user_id
    from rooms_users
    where user_id = (select auth.uid())
      and room_topic = (select realtime.topic())
      and realtime.messages.extension in ('broadcast', 'presence')
  )
);

Add a matching for insert policy with with check to control who can send messages. The function realtime.topic() returns the topic that the client joins. Use it to link the topic name to a membership row.

Warning: Do not run alter table realtime.messages enable row level security. The documentation states that RLS is already on for this table. The command fails with an ownership error and stops the rest of the migration.

How do I turn off public access?

  1. Open your project in the Supabase dashboard.
  2. Go to Realtime, then Settings.
  3. Turn off Allow public access.
  4. Save and test again with the unauthenticated script.

According to the documentation, if the option stays on, clients can still reach channels without authorization. Turn it off only after all your channels use private: true. Otherwise public channels in your app stop working.

How do I handle DELETE events?

You cannot use RLS to filter DELETE events. Use one of these designs:

Design How it works
Soft delete Set a deleted_at column with an UPDATE. UPDATE events follow RLS
No private data in the key Use a random UUID as the primary key and not an email or a name
Do not subscribe to deletes Subscribe to INSERT and UPDATE only, and refresh the list after a delete
Private Broadcast Send an event to a private channel from your server code

Do not set replica identity full on a table with private data unless you need it. The default setting sends less data in a DELETE event. Read the Postgres Changes documentation before you change it.

How do I check what other users already saw?

Assume that all data that passed through the open table or channel is read. You cannot rebuild a list of who received which event. Use these steps to measure the exposure.

  1. List the open tables. Run the check below to find tables without RLS.
  2. List the Realtime tables. Read the supabase_realtime publication to see which tables send changes.
  3. List the open channels. Search the code for .channel( and check each one for private: true.
  4. Find what the events held. Read each table and write down the columns. Mark emails, names, messages, tokens, and payment data.
  5. Check the Realtime logs. Open the logs of your project and look for connections that use only the public key. Do not expect a record of the content of each event.
  6. Check for other leaks. If Realtime was open, the REST API of the same table was probably open too. Test it with a curl call and the public key.
select schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;

select schemaname, tablename
from pg_publication_tables
where pubname = 'supabase_realtime';

The first query shows rowsecurity = false for tables without RLS. The second query lists the tables that Realtime publishes. A table that appears in both lists with false is open. Fix these tables first.

If the data included personal data of other people, you may have a duty to tell the owners of the data. Ask a legal advisor if you are in doubt.

Why do users still see data after I change the policy?

Realtime holds policy results for the length of a connection. The documentation states that “policies are cached for the connection, so revoked access persists until the JWT expires or a new token is sent.”

This has two effects. A user who had access can keep receiving data for a short time after you remove the access. A new policy also does not apply to open connections at once.

To apply a change fast, close the open channels and start them again. Ask the clients to refresh the page. Keep the lifetime of the JWT short, as the documentation recommends. The JWT expiry is a setting in the Auth settings of the project.

How do I stop this problem from coming back?

Use these controls so that a new feature does not open the data again.

  1. Turn on RLS for each new table. Use a migration template that includes enable row level security.
  2. Write the policy before you add the table to Realtime. Add the table to the supabase_realtime publication only after the policy exists.
  3. Use private: true for each channel. Add this rule to the instructions of your coding tool.
  4. Do not use client filters as security. Review each filter: in a pull request and check that a policy exists for the same rule.
  5. Test with two users in each release. Keep the script from this guide in your repository.
  6. Keep secrets out of events. Send IDs and let the client fetch the data through a call that RLS protects.
  7. Scan the live app. A scan connects to Realtime in the same way as an attacker. See API abuse protection for related controls.

When should I get help?

Get help in these cases:

  • The test shows that anonymous users received private data, and the table holds personal or payment data.
  • You do not know which tables the app exposes, and the project has many tables.
  • Policies refer to each other and you get recursion errors. Fix the policy design before you add Realtime.
  • The leak stays after you turn on RLS and make the channels private. Check that the test script used the right token and the right project.
  • You must report a data breach. Ask a security engineer to help you to write the facts.

After you finish, run the two-user test one more time. Then run it again with no sign-in. Both tests must show no private data.

Common questions

Does a filter in the client code stop other users from getting my rows?
No. The client chooses the filter, so an attacker can choose a different one. Only Row Level Security policies decide which rows a user can receive.
Does Postgres Changes use RLS?
Yes, for inserts and updates. Supabase checks each event against each subscriber. DELETE events are an exception, because RLS policies do not apply to them.
Is a Broadcast channel private by default?
Not when you create it in the client without the private flag. A public channel lets anyone subscribe without authentication. Set private to true and add policies.
Do I need to turn off Allow public access?
Yes, if you want to enforce private channels. With the option on, clients can still reach channels without authorization. The option is in the Realtime settings.
Can a user keep receiving data after I remove their access?
Yes, for a short time. Realtime caches the policy result for the connection until the token expires or the client sends a new token. Keep token lifetimes short.

Find open realtime data

A VibeEval scan connects to the Realtime service of your deployed app with the public key and reports tables and channels that send data to users who must not see it.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →