Here's an uncomfortable default: on a stock self-hosted Supabase instance, any client holding your anon key can subscribe to any Realtime channel. If your chat app broadcasts messages on room:42, nothing stops a curious user from opening the browser console and joining room:43, room:44, and every other room — reading broadcasts and watching presence state for users they should never see. Your database tables are protected by Row Level Security, but public Broadcast and Presence channels sit outside it entirely.
Realtime Authorization closes that gap. It extends the RLS model you already use to WebSocket channels, so the same policies that guard your tables decide who can send and receive realtime messages. It works fully on self-hosted instances — but unlike Supabase Cloud, there's no dashboard toggle doing the setup for you. This guide covers how authorization actually works, the SQL policies you need, the self-hosted-specific configuration, and the pitfalls that catch people in production.
If you haven't got Realtime running yet, start with our Realtime configuration guide for self-hosted instances and come back — this post assumes the service is up and clients can connect.
How Realtime Authorization Works
The mechanism is elegant: authorization decisions are RLS policies on a special table, realtime.messages. When a client subscribes to a channel marked private, the Realtime server runs a check against Postgres with that client's JWT loaded into the transaction, exactly like a normal PostgREST request. auth.uid() resolves to the subscriber, your policies run, and the result determines access:
- SELECT policies on
realtime.messagescontrol who can receive — broadcasts and presence updates. - INSERT policies control who can send — publishing broadcasts or tracking presence.
Under the hood, Realtime inserts a test message and attempts to read it back with your policies applied, then rolls the transaction back. Nothing is persisted; the policy verdict is cached on the Realtime server so subsequent messages flow at full speed without a database round-trip per frame. Per Supabase's authorization docs, you'll see a small latency bump at subscription time and essentially nothing after.
Two things follow from this design that are worth internalizing:
- Policies are checked at subscribe time, not per message. If you revoke a user's room membership, their existing subscription keeps working until their access token refreshes and the policy re-evaluates. Plan for that window (more below).
- Public channels bypass all of it. Authorization only applies to channels the client opens with
private: true. A policy onrealtime.messagesdoes nothing for a public channel — which is why enforcing private-only matters.
Writing the Policies
Policies key off two helpers: realtime.topic(), which returns the channel name being subscribed to, and the extension column, which is 'broadcast' or 'presence'. A typical pattern uses structured topic names like room:42 and checks membership against your own tables.
Say you have a room_members table. Allow members to receive broadcasts and presence on their rooms:
create policy "members can receive room messages"
on realtime.messages
for select
to authenticated
using (
extension in ('broadcast', 'presence')
and exists (
select 1
from public.room_members rm
where rm.room_id = split_part(realtime.topic(), ':', 2)::bigint
and rm.user_id = (select auth.uid())
)
);
And allow the same members to send:
create policy "members can send room messages"
on realtime.messages
for insert
to authenticated
with check (
extension in ('broadcast', 'presence')
and exists (
select 1
from public.room_members rm
where rm.room_id = split_part(realtime.topic(), ':', 2)::bigint
and rm.user_id = (select auth.uid())
)
);
A few notes that save debugging time:
- Wrap
auth.uid()in a sub-select —(select auth.uid())— so Postgres evaluates it once rather than per row. Same RLS performance rule as everywhere else. - You can split send and receive asymmetrically. A live-dashboard pattern might let
authenticatedusers SELECT but only a service publish INSERTs. Read-only viewers in a collaborative app get the SELECT policy and nothing else. - Keep topic parsing defensive.
split_part(...)::bigintthrows on garbage topics likeroom:abc, and an error inside a policy denies access — which is the failure mode you want. - Index the lookup columns. The policy runs on every subscription;
room_members(room_id, user_id)should have an index backing it.
On the client, opt into authorization per channel:
const channel = supabase.channel('room:42', {
config: { private: true },
});
channel
.on('broadcast', { event: 'message' }, handleMessage)
.subscribe((status) => {
if (status === 'CHANNEL_ERROR') {
// policy denied the subscription
}
});
If the policy denies access you'll get the error "You do not have permissions to read from this Channel topic" — that string in your logs almost always means a policy gap, a topic-name mismatch, or a stale JWT.
Self-Hosted Specifics: What Cloud Does for You
On Supabase Cloud there's a dashboard setting — "Allow public access" — that forces every channel on the project to be private. Self-hosted, you need to handle the equivalent pieces yourself:
1. Run a recent Realtime image. Broadcast and Presence authorization shipped in Realtime v2.x releases from mid-2024 onward; any current image supports it. If your deployment dates back further, check your docker-compose.yml image tag before anything else. Our upgrade guide covers moving service versions safely.
2. Verify the JWT secret matches everywhere. Realtime validates the subscriber's token with API_JWT_SECRET. If it drifts from the JWT_SECRET used by Auth and PostgREST, authorization fails in confusing ways — subscriptions to private channels error out even with correct policies. This is the classic multi-service environment variable consistency problem, and it bites hardest here because the error surfaces client-side as a generic permission denial.
3. Enforce private-only at the tenant level. Client-side private: true is opt-in, which means a malicious client can simply not opt in and use public channels. The Cloud toggle maps to a private_only flag on the Realtime tenant; on self-hosted you can set it on your tenant record in the _realtime.tenants table (column availability depends on your Realtime version — check the schema first):
update _realtime.tenants set private_only = true where external_id = 'realtime-dev'; -- your tenant's external_id
After this, public-channel subscriptions are rejected outright, and every channel must pass your policies. For production, this is the setting that makes the whole scheme airtight rather than advisory.
4. Handle token refresh. Access tokens expire (default: one hour). Long-lived WebSocket connections need the refreshed token pushed to Realtime, or re-authorization fails mid-session:
supabase.auth.onAuthStateChange((event, session) => {
if (session) supabase.realtime.setAuth(session.access_token);
});
Recent supabase-js versions handle much of this automatically, but if users get mysteriously disconnected around the one-hour mark, this is where to look. More on token lifetimes in our JWT and session security guide.
Testing Your Policies
Don't trust policies you haven't watched deny someone. A minimal test matrix:
| Scenario | Expected |
|---|---|
| Member subscribes to their room (private) | Subscribed |
| Non-member subscribes to that room | CHANNEL_ERROR |
| Member sends broadcast | Delivered |
| Read-only role sends broadcast | Send rejected |
Anonymous client, public channel, private_only on | Rejected |
The last row is the one most teams skip — and it's the one that verifies your instance actually enforces privacy rather than politely suggesting it. If you're running pgTAP tests already, you can unit-test the policy logic itself by setting request.jwt.claims and querying realtime.messages directly, then keep one end-to-end WebSocket test for the full path.
One honest trade-off to note: because policy results are cached per subscription, Realtime Authorization is not a tool for instant revocation. Kicking a user from a room takes effect on their next token refresh or reconnect — typically minutes, not milliseconds. If your threat model needs immediate cutoff, terminate their session server-side and force a disconnect rather than relying on the policy cache expiring.
Where This Fits in a Managed Setup
Realtime Authorization touches three services at once — Auth (JWT issuance), Postgres (policies), and Realtime (enforcement) — which is exactly the kind of cross-service configuration that's fragile when you're hand-editing a Docker Compose file over SSH. Supascale keeps the JWT secret and service environment consistent across your stack, lets you deploy only the services you need (if an API-only project doesn't use Realtime, don't run it), and its automated backups capture your policies along with the rest of your schema — so a restore brings back your authorization rules, not just your data. One perpetual license from $99 covers unlimited projects, which matters once each project carries this much per-service configuration.
Realtime Authorization is one of those features where self-hosted parity with Cloud is genuinely complete — the enforcement engine is identical. The difference is purely operational: you own the version, the secrets, and the tenant flag. Set all three deliberately, write asymmetric send/receive policies against your membership tables, test the deny paths, and your channels are as locked down as your tables.
