Realtime features are the place authorization is most easily forgotten. You carefully wrote RLS on the database tables, but the moment you add "live chat or cursor sharing," an outsider who merely knows the room's topic name can eavesdrop on and speak in the conversation — this is a hole that actually happens often. Because realtime messages don't go through an ordinary table query, the table's RLS doesn't take effect.
Supabase has a clear answer to this problem. Realtime authorization is determined by RLS on a dedicated table called realtime.messages (Supabase: Realtime Authorization). When a user connects to a channel, that permission is computed based on the RLS policy, JWT claims, and the connected topic. In other words, the RLS thinking you've used in the database works for realtime as-is.
This article explains how to correctly authorize Supabase Realtime's three features — Broadcast (arbitrary messages), Presence (presence sharing), Postgres Changes (DB-change notifications) — with RLS. The subject is the real-time match-recording app (multiple people scoring the same match simultaneously, all-RLS, 280-policy operation) — a product where, unless realtime authorization is truly effective, it becomes an accident. The content is faithful to Supabase official and PostgreSQL RLS official (as of June 2026).
Premise: the basics of RLS (USING/WITH CHECK, roles,
(select)optimization) were covered in RLS for beginners and performance optimization. This article applies that to Realtime (realtime.messages).
1. The big picture: three features and "two authorization mechanisms"
It's easy to get confused, so let me organize it first. Supabase Realtime has three features and two authorization mechanisms.
| Feature | What it does | Authorization mechanism |
|---|---|---|
| Broadcast | Arbitrary low-latency messages (chat, cursor, etc.) | RLS on realtime.messages |
| Presence | Sharing who's online now | RLS on realtime.messages |
| Postgres Changes | Table INSERT/UPDATE/DELETE notifications | The target table's own RLS |
- Broadcast / Presence judge "may you send/receive this topic" with RLS on the
realtime.messagestable (§3–§5). - Postgres Changes is separate and respects the RLS of the table where the change occurred. Change events for rows you can't read don't arrive (§6).
Grasping this "different layer" first keeps the design from wobbling.
2. Enable the private channel (client side)
realtime.messages RLS takes effect when you connect to a private channel. Specify private: true on the client and pass the user's JWT with setAuth(). Forget this and the authorization layer doesn't work, and the room becomes unintentionally loose.
import { createClient } from "@/lib/supabase/client";
const supabase = createClient();
// 認証トークンをRealtimeに渡す(RLSがJWTクレームを見られるようにする)
await supabase.realtime.setAuth(); // セッションのaccess_tokenを使う
const channel = supabase.channel("room:match-42", {
config: { private: true }, // ← これでrealtime.messagesのRLSが評価される
});
channel
.on("broadcast", { event: "score" }, (payload) => {
console.log("score update:", payload);
})
.subscribe((status, err) => {
if (status === "SUBSCRIBED") {
// 認可された。送信できる
channel.send({ type: "broadcast", event: "score", payload: { home: 3 } });
} else if (err) {
console.error("realtime auth failed:", err); // ポリシーで拒否された等
}
});
config: { private: true } is the authorization switch. A channel without this isn't subject to RLS evaluation. Always make confidential rooms private: true.
3. Topic and extension: the policy's two axes
When writing RLS on realtime.messages, there are two values used for the judgment.
realtime.topic()— a function returning the channel's topic the user is trying to connect to (in the example above,"room:match-42"). It represents "which room."- The
extensioncolumn — represents whether that message is'broadcast'or'presence'. It represents "which feature."
With these two axes, express "may this user use this feature of this topic." JWT claims can be referenced with auth.uid() or current_setting('request.jwt.claims')::json.
A minimal example (official) — "an authenticated user may receive the room-1 topic":
create policy "authenticated can read room-1"
on realtime.messages for select to authenticated
using ( (select realtime.topic()) = 'room-1' );
Wrapping with (select realtime.topic()) is the performance optimization that avoids per-row re-evaluation. In real operation, don't make the topic a fixed value — match it against membership (next chapter).
4. Broadcast: permit receiving (SELECT) and sending (INSERT) separately
The crux of a real app is "only members of that room can send/receive." Manage the room's members in a table like rooms_users(user_id, room_topic) and match with RLS. The key point is to write receiving as SELECT (USING) and sending as INSERT (WITH CHECK) separately — the division of USING and WITH CHECK is the same here.
-- 受信:メンバーは、自分が所属するトピックのbroadcastを受け取れる
create policy "members can receive broadcast"
on realtime.messages for select to authenticated
using (
exists (
select 1 from public.rooms_users
where user_id = (select auth.uid())
and room_topic = (select realtime.topic())
and realtime.messages.extension in ('broadcast')
)
);
-- 送信:メンバーは、自分が所属するトピックにbroadcastを送れる
create policy "members can send broadcast"
on realtime.messages for insert to authenticated
with check (
exists (
select 1 from public.rooms_users
where user_id = (select auth.uid())
and room_topic = (select realtime.topic())
and realtime.messages.extension in ('broadcast')
)
);
Why separate sending and receiving: because the requirements tend to be asymmetric. "Everyone can read but only the moderator can speak," "spectators receive only, scorers can send" — making SELECT and INSERT separate policies naturally expresses such differences. In my match-recording app, I implemented the distinction that scorers can broadcast score updates but spectators are receive-only in this form.
Put a composite index on rooms_users(user_id, room_topic) to make the exists judgment a single index lookup.
5. Presence: presence info in the same framework
Presence ("who's in this room now") is also authorized with realtime.messages RLS, using extension in ('presence'). The structure is the same as Broadcast.
-- 在席の受信:メンバーは同室の在席状況を見られる
create policy "members can read presence"
on realtime.messages for select to authenticated
using (
exists (
select 1 from public.rooms_users
where user_id = (select auth.uid())
and room_topic = (select realtime.topic())
and realtime.messages.extension in ('presence')
)
);
-- 在席の送信:メンバーは自分の在席をトラックできる
create policy "members can track presence"
on realtime.messages for insert to authenticated
with check (
exists (
select 1 from public.rooms_users
where user_id = (select auth.uid())
and room_topic = (select realtime.topic())
and realtime.messages.extension in ('presence')
)
);
If you permit Broadcast and Presence under the same condition, you can consolidate into one with extension in ('broadcast', 'presence') to be DRY (the official docs show the same pattern). Split if the conditions branch, bundle if common — extract duplication only when it's real.
-- 条件が同じなら1本に束ねる(DRY)
create policy "members can listen broadcast and presence"
on realtime.messages for select to authenticated
using (
exists (
select 1 from public.rooms_users
where user_id = (select auth.uid())
and room_topic = (select realtime.topic())
and realtime.messages.extension in ('broadcast', 'presence')
)
);
6. Postgres Changes: the target table's own RLS takes effect
The third feature, Postgres Changes (realtime notification of table changes), has a different mechanism. Not realtime.messages, but the RLS of the table where the change occurred is evaluated. In Supabase official's words, "for Postgres Changes on an RLS-enabled table, records are sent only to clients with read permission via that table's RLS" (Supabase: Realtime Authorization).
// matchesテーブルの変更を購読。届くのは「RLSで自分が読める行」の変更だけ
const channel = supabase
.channel("db:matches")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "matches" },
(payload) => console.log("change:", payload),
)
.subscribe();
This is the clean point. If you've written RLS like using ((select auth.uid()) = user_id) on the matches table, change events for others' matches don't arrive. You don't need to write new authorization; the table's RLS works for realtime as-is. "Protect once in the DB, and it's protected in realtime too" — this is the strength of Supabase's RLS-centric design.
But a caution: at scale, Postgres Changes incurs the cost of evaluating RLS per client. For many concurrent connections, a design that receives DB changes on the server and converts them to Broadcast (realtime.broadcast_changes) is sometimes recommended. Choose by the requirement scale.
7. Operational key points: JWT expiry, re-authentication, policy caching
Realtime maintains a connection for a long time, so token-lifetime management is more important than for ordinary requests. Grasp the official behavior (Supabase: Realtime Authorization).
- The policy is cached during the connection. It isn't re-evaluated on every message.
- Sending a new JWT in an
access_tokenmessage updates it. - If a new token doesn't arrive before the JWT expires, the client is disconnected.
So for long connections, operate to re-call setAuth() in step with token refresh. When the token is updated via @supabase/ssr, pass the new token to Realtime too.
// トークンが更新されたらRealtimeにも反映し、切断を防ぐ
supabase.auth.onAuthStateChange((event, session) => {
if (session) {
supabase.realtime.setAuth(session.access_token);
}
});
This is the typical cause of "it worked at first but realtime cuts off after tens of minutes." Remember to update before the connection's lifetime > the token's lifetime.
Performance and correctness
- Wrap
auth.uid()andrealtime.topic()in the policy in(select ...)(avoiding re-evaluation). - Put a composite index on
rooms_users(user_id, room_topic). - And finally — test that "members can send/receive, outsiders are rejected." Realtime authorization, precisely, looks like it "worked" in a demo, and outsider eavesdropping is hard to notice. Fix the allow/deny of
realtime.messageswith pgTAP and stop regressions in CI.
Conclusion: protect realtime "with RLS" too
- Realtime's Broadcast / Presence are authorized with RLS on
realtime.messages. The client connects withprivate: true+setAuth(). - The judgment axes are
realtime.topic()(which room) andextension(broadcast/presence). Match against membership and permit receiving = SELECT (USING) / sending = INSERT (WITH CHECK) separately. - Postgres Changes is a separate mechanism that respects the target table's own RLS. Protect in the DB and you're protected in change notifications too.
- The connection is long-lived. Without updating with
setAuth()before the JWT expires, it disconnects. Fast with the(select)wrap and an index, safe with a deny test.
With this, you can protect all three faces — data (tables), files (Storage), and realtime (this article) — consistently with the same RLS philosophy. Protect authorization with "the DB's structure," not "the app's goodwill" — that's the path to achieving zero trust on Supabase.