← back to research

Checked on state, skipped on diff: a presence authorization gap in Supabase Realtime

Supabase Realtime enforces a presence.read policy on the initial presence snapshot but not on the ongoing presence_diff fan-out, so a member allowed to broadcast but denied presence can still read every other member's presence metadata on a private channel.


Supabase Realtime is the channel layer a great many applications use for presence, broadcast, and database-change streams. Its Realtime Authorization model lets an operator write row-level security policies on realtime.messages that decide, per extension, who may do what on a channel, including a presence.read policy that governs who may read a private channel’s presence roster, independently of who may read its broadcasts. We found that a correctly written presence.read policy is only half-enforced.

IMPORTANT

Who’s exposed. You’re affected if you run Supabase Realtime (2.111.1 and earlier) with Realtime Authorization on a private channel and a presence.read policy stricter than broadcast.read: the “members may chat, but only some may see the roster” shape. A member who can read broadcasts but is denied presence is correctly refused the initial roster, yet still receives every presence_diff, so any presence metadata you treat as restricted leaks to them. You’re not affected once you upgrade to 2.111.2, or where presence.read matches broadcast.read (there is no differential to exploit). Confidentiality only: no integrity or availability impact, and postgres_changes row data is untouched.

Two ways presence leaves the server

Presence reaches a client by two paths. When a client joins, it receives an initial presence_state: a snapshot of who is currently present. After that, as members come and go, the server fans out presence_diff events with the incremental changes. Realtime checks presence.read on the first path and not the second.

A client that holds broadcast.read but is explicitly denied presence.read joins a private channel (the join gate checks only broadcast.read, so it succeeds). It is correctly withheld the initial presence_state. And then it receives every presence_diff anyway, each one carrying another member’s presence metadata: arbitrary, developer-defined JSON the policy was written to keep from it.

The tell: the gate is on one path, not its sibling

As with most authorization gaps worth reporting, the check is not missing from the codebase: it is present on one path and absent on the one beside it. The initial snapshot is guarded: presence_handler.ex wraps the state push in a can_read_presence? check, and a socket that cannot read presence simply gets nothing. The diff fan-out in message_dispatcher.ex carries no such check. It pushes the presence_diff to every fastlane subscriber on the topic. The private-channel join gate, in turn, only ever evaluates broadcast.read.

What makes this a precise omission rather than a broken model is that the sibling egress paths enforce correctly. Per-subscriber postgres_changes are filtered by RLS and column grants; presence writes are gated by presence.write; broadcast writes by broadcast.write. The model is sound everywhere except this one read path, where the policy is computed and then never consulted.

Reproducing it

The operator does everything right. A presence.read policy names exactly one user as allowed to read the roster, while both users may read broadcasts:

-- both members may READ broadcasts on the channel
create policy rt_broadcast_read on realtime.messages for select to authenticated
  using ( extension = 'broadcast' and realtime.topic() = 'private:roomA' );

-- but only user A may READ presence; B is explicitly denied
create policy rt_presence_read on realtime.messages for select to authenticated
  using ( extension = 'presence' and realtime.topic() = 'private:roomA'
          and (select auth.uid()) = '<user-A-uuid>' );

Then:

  1. Users A and B both join the private channel with presence enabled. Both joins succeed: both hold broadcast.read.
  2. A publishes a presence payload, say { "secret": "PRESENCE-CANARY-A", "user_id": "<A>" }.
  3. On B’s socket, the initial presence_state is {} (the presence.read gate holds, an intrinsic negative control). But the subsequent presence_diff arrives carrying A’s metadata.

Same principal, same channel: correctly denied the snapshot, incorrectly handed the diff. The policy is evaluated correctly and enforced on only one of the two ways presence leaves the server.

Impact

Any presence metadata an application treats as restricted (live location, who is online, “currently viewing” rosters, typing indicators keyed to user ids) leaks to every broadcast-authorized member of a private channel. The exposure is bounded to the confidentiality of presence payloads: there is no integrity or availability impact, and postgres_changes row data is unaffected.

It applies wherever presence visibility is configured to be more restricted than broadcast visibility: “members may chat, but only admins may see the roster.” That is a supported Realtime Authorization shape, if not the most common one; where presence.read matches broadcast.read, there is no differential to exploit.

Remediation

Apply the presence.read authorization to the presence_diff egress path in message_dispatcher.ex, mirroring the can_read_presence? gate already enforced on presence_state in presence_handler.ex. The per-subscriber policy is already computed and cached on the socket, so this is the same check the snapshot path already makes, placed on the path that is missing it. The regression test that would have caught it is the one to add: a member with broadcast.read and without presence.read must receive neither the state nor the diff.

Disclosure

  • Software: Supabase Realtime 2.111.1 and earlier, with Realtime Authorization and a presence.read policy stricter than broadcast.read.
  • Class: CWE-863, incorrect authorization (presence metadata disclosure). Confidentiality only.
  • Severity: Moderate, CVSS 6.5.
  • CVE: CVE-2026-62247.
  • Advisory: GHSA-rcr8-2525-4r7p.
  • Fix: 2.111.2. Reported to Supabase.
  • Reported by: Cipher / Causal Security.

Reproduced in a self-contained lab against a Realtime instance we controlled; the users and channel are synthetic.

Why we look at egress paths, not endpoints

An authorization model is only as strong as its most-forgotten exit. Here the policy was written correctly and computed correctly; it was simply enforced on one of the two paths the same data uses to leave the server. Reasoning about the join handler alone would not surface it: the join is supposed to succeed, because the member really can read broadcasts. The gap is only visible when you enumerate every path a piece of state can exit by and confirm the gate is on all of them. It is the same shape as a client-binding check that lived on one path and not its sibling: the bug is in the egress nobody re-checked, not in the policy itself.


This work is part of our ongoing vulnerability research into the open-source API and realtime infrastructure that everyone else builds on.