---
title: "A private channel nobody else can join"
description: "The who's-online bar in our admin had not connected for three months, and nothing on screen said so. Allowing the socket in our CSP would have taken five minutes and switched back on a channel anyone could read and spoof. Here is what we built instead: a short-lived token, a private channel, and two row-level security rules, plus two lessons from putting them into production."
url: "https://bookatu.com/engineering/a-private-channel-nobody-else-can-join"
language: "en"
section: "Engineering"
category: "Engineering"
author: "The Bookatu engineering team"
published: "2026-10-28"
updated: "2026-10-28"
tags: ["security", "realtime", "postgres", "csp", "engineering"]
---

# A private channel nobody else can join

The who's-online bar in our admin had not connected for three months, and nothing on screen said so. Allowing the socket in our CSP would have taken five minutes and switched back on a channel anyone could read and spoof. Here is what we built instead: a short-lived token, a private channel, and two row-level security rules, plus two lessons from putting them into production.

> **The short version**
>
> - The who's-online bar in our admin had not connected since 28 July. That day we started enforcing our Content-Security-Policy, and its connect-src did not list the Realtime websocket. A blocked socket shows the user nothing at all.
> - Adding that origin to the policy would have taken five minutes, and it would have switched a public channel back on. Anyone holding the public key and an org id could have read it and added fake people to it.
> - What we shipped instead: a fifteen-minute token that the server signs only for a signed-in member, a private channel, and two row-level security policies on realtime.messages that match the channel topic to the org_id claim inside that token.
> - Two lessons from production. The migration first tried to ALTER a table our migration user does not own, and failed safely inside one transaction. Then a hand-written probe was refused, because the realtime client only turns presence on when the channel has a presence listener.

Our admin has a small avatar stack in the header showing who else from the business has the admin open, the way a shared document shows other editors. It runs on Supabase Realtime presence. Everyone in a business joins one channel, tracks themselves, and draws whoever else is there. When realtime is not available the bar degrades to just you, with the words "Only you here" beside it.

That degradation is the whole reason this story took three months to notice. A business with one person in the admin sees "Only you here" whether the feature works or not. A business with three people sees it too, if the socket never opens. The failure looked exactly like a quiet afternoon.

## How the bar went dark

On 28 July we moved our CSP from report-only to enforced. It had sat in report-only for months, and before that change it had no report-uri either, so it blocked nothing and collected nothing. Enforcing it was the right call. connect-src limits where injected script can send data, which is most of what an XSS is for. We went through the allowlist carefully: Stripe, reCAPTCHA, analytics beacons. This is the connect-src that shipped that day:

```ts
"connect-src 'self' https://*.stripe.com https://api.stripe.com https://www.google.com https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com",
```

There is no Realtime origin in it. The presence client opens a websocket to the project's Realtime host, the enforced policy refused it, and the bar stayed at its starting state. The bar's code wraps everything in a try/catch on purpose, because presence is a nice-to-have and must never crash the admin. That was deliberate, and it meant nothing on screen ever looked wrong.

We were not entirely blind. A stricter report-only policy shipped alongside the enforced one, and it had the same connect-src gap plus a report-uri, so the browser reported the violation. The enforced policy blocked the same request. Nobody was reading those reports with the who's-online bar in mind.

## The five-minute fix we did not ship

Once we found it, the obvious fix was one more origin in connect-src. Before adding it, we read what the bar was actually joining:

```tsx
channel = supa.channel(topic, { config: { presence: { key: me.id } } });
channel.on("presence", { event: "sync" }, () => {
  const state = channel!.presenceState<Person>();
  // ...
});
channel.subscribe((status) => {
  if (status === "SUBSCRIBED" && channel) void channel.track(me);
});
```

That is a public channel on the topic presence:org:<org id>, opened with the anon key. The anon key is public by design. It ships in every browser bundle, because the browser client needs it. So anyone who had it and knew an org id could subscribe and see who was online, with names and avatars. They could also call track() and appear in someone else's admin as a person who does not exist.

So the CSP gap that broke the feature had also, by accident, been the only thing keeping that channel shut. Allowing the socket on its own would have fixed the bug by reopening the hole.

> Before you remove the thing that is blocking a feature, find out what else it has been blocking.

## A socket is not access

We still needed the socket, so the CSP change came first and stayed narrow. connect-src gains exactly one origin, built at build time from the same public Supabase URL the browser client uses. It is never a wildcard: wss://*.supabase.co would let injected script talk to any Supabase project anywhere, which is the very thing connect-src exists to prevent. Anything that does not parse as a plain https URL with an ordinary host adds nothing, and the policy stays byte for byte what it was.

```ts
function realtimeOrigin(): string | null {
  const raw = process.env.NEXT_PUBLIC_SUPABASE_URL;
  if (!raw || !raw.trim()) return null;
  let url: URL;
  try {
    url = new URL(raw.trim());
  } catch {
    return null;
  }
  if (url.protocol !== "https:" || url.username || url.password) return null;
  // An ordinary DNS name, optionally with a port. Rules out "*" and anything
  // else that could mean more than this one host inside a CSP source list.
  if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:\d{1,5})?$/i.test(url.host)) return null;
  return `wss://${url.host}`;
}
```

There is no https:// entry for the same host. The realtime client always hands its transport a WebSocket and never turns on the long-poll fallback, and presence does not use the REST broadcast endpoint. The embeddable booking widget has its own policies and does not get the origin either, because it never opens a socket.

Opening a socket is now allowed. Joining our channel is a separate question.

## A token the server signs only for members

Supabase Realtime has private channels. A client may join one only when row-level security on the realtime.messages table allows it, and that check runs as the role in the client's token, with that token's claims. That means the token's claims are the access control, so we decided to mint our own token rather than lean on the anon key.

```ts
const iat = Math.floor((input.now ?? Date.now()) / 1000);
const exp = iat + REALTIME_TOKEN_TTL_SECONDS; // 15 * 60
const header = { alg: "HS256", typ: "JWT" };
const claims = {
  role: "authenticated",
  aud: "authenticated",
  sub: input.userId,
  org_id: input.orgId,
  iat,
  exp,
};
const signingInput = `${b64url(header)}.${b64url(claims)}`;
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest("base64url");
```

role is the role Realtime runs the check as. That role has no grants on our application schema, so the token reaches no data at all. All it can do is satisfy the two rules described below. org_id names exactly one business. exp is fifteen minutes, so a copied token stops being useful soon after. It is signed with HS256 on node:crypto, the same way the codebase already signs its other tokens. If the signing secret is not configured we mint nothing, and the bar shows just you, the same as when realtime is off.

The minting function deliberately checks nothing except its inputs. The decision about who gets a token lives in one route, POST /api/realtime/token. That route does not copy the admin's access rules. It calls the same guard the admin layout calls, so the token goes to exactly the people the layout would draw the bar for:

```ts
const user = await getCurrentUser();
if (!user) return refuse(401);

const orgId = await readOrgId(req);
if (!orgId) return refuse(400);
const org = await getOrgById(orgId);
if (!org) return refuse(404);

let access: Awaited<ReturnType<typeof requireOrgAccess>>;
try {
  access = await requireOrgAccess(org.slug);
} catch (e) {
  const status = guardStatus(e);
  if (status === null) throw e;
  return refuse(status);
}

if (access.org.status === "suspended" && !access.isPlatformOwner) return refuse(403);
if (access.org.id !== org.id || access.user.id !== user.id) return refuse(403);

const grant = mintRealtimeToken({ userId: access.user.id, orgId: access.org.id });
if (!grant) return refuse(503);
```

A few details mattered here. The session check runs before the body is even read, so a caller with no session learns nothing about whether their request was well formed. The guard was written for pages, so it says no by redirecting or calling notFound(). guardStatus turns those into bare status codes and rethrows anything else as a real fault. The org id in the body is only ever a question. The token names the business the guard actually let the caller into, never one the client asserted. Every response, refusals included, is sent with Cache-Control: no-store.

## The rules are the access control

Migration 0160 adds two policies to realtime.messages. One is for SELECT, which lets a client receive presence. One is for INSERT, which lets it track itself. Both use the same condition:

```sql
create policy bookatu_presence_read
  on realtime.messages
  for select
  to authenticated
  using (
    realtime.messages.extension = 'presence'
    and realtime.topic() = 'presence:org:' || (auth.jwt() ->> 'org_id')
  );
```

realtime.topic() and auth.jwt() are Supabase's own functions: the topic of the channel being checked, and the claims of the token. So a token for one business matches that business's presence topic and nothing else. It cannot see another business's topic, and it cannot use broadcast even on its own. A token with no org_id claim, such as an ordinary Supabase-auth session, matches nothing, because 'presence:org:' || NULL is NULL. The anon key gets no rule at all.

Each of those statements is a test. The realtime schema only exists on Supabase, and locally and in every test our database is PGlite. So the migration test builds a small Supabase-shaped stand-in: a realtime.messages table with row-level security on, plus the two functions defined as Supabase defines them. It then checks receive and track as each kind of caller: our own topic, another business's topic, broadcast on our own topic, a token with no org_id, and anon. On PGlite itself the real migration is a guarded no-op, because every PGlite suite replays every migration.

## Joining, and staying joined

In the browser, the bar fetches a token, gives it to Realtime, and only then joins, with private: true:

```tsx
await supa.realtime.setAuth(grant.token);
// ...
const ch = supa.channel(topic, { config: { private: true, presence: { key: me.id } } });
channel = ch;
ch.on("presence", { event: "sync" }, () => {
  // rebuild the avatar list from ch.presenceState()
});
ch.subscribe((status) => {
  if (status === "SUBSCRIBED" && !gone) void ch.track(me);
});
scheduleRenewal(grant.expiresAt);
```

A fifteen-minute token means renewing it, and renewal turned out to be most of the client code. The bar asks for a new token three minutes before the current one ends, with the wait clamped between thirty seconds and twelve minutes so a skewed clock still renews in time. It hands the new token to the joined channel with setAuth, which needs no rejoin. If a renewal fails, it retries every thirty seconds while the old token lasts. If none arrives in time, the bar leaves on its own instead of waiting to be dropped, and shows only what it knows: you.

The bar also separates two kinds of no. A definite no, such as not signed in, not a member, or realtime not set up (the route's 503), is not retried on a timer. A no that may pass, such as being offline or a server fault, is retried on a backoff that starts at thirty seconds and doubles up to five minutes. It also retries at once when the browser comes back online or the page is shown again. A laptop that wakes from sleep before its Wi-Fi does should not need a reload to see its colleagues.

## Lesson one: the table was not ours

The first version of the migration also switched row-level security on for realtime.messages, as a belt to go with the braces. On the production apply it failed with "must be owner of table messages". On Supabase, our migration user does not own that table.

It failed safely. The file runs as a single transaction, so the error took the whole file down and nothing was applied. There was no half-migrated state with policies on a table whose row-level security we were unsure of. The fix was to stop trying to change a table that belongs to someone else, and to check it instead:

```sql
if not (select relrowsecurity from pg_class where oid = 'realtime.messages'::regclass) then
  raise exception 'row-level security is off on realtime.messages; switch it on in Supabase first. Nothing was changed.';
end if;
```

Supabase keeps row-level security on for that table, so in practice this check always passes. It is there so that our policies can never be the only thing standing in for it. The test suite now pins both behaviours: the file never alters realtime.messages, and it raises without adding any rule when row-level security is off.

One more operational detail. CREATE POLICY takes an ACCESS EXCLUSIVE lock on a table that Realtime itself reads, so the file sets lock_timeout to five seconds. If the table is busy, the migration fails, changing nothing, and the apply script tries it up to six times in all, retrying only on the lock-not-available error code. Any other error stops it immediately. After applying, the script reads pg_policies back and fails unless both rules are present, permissive, for authenticated only, and checking the extension, the topic and the org_id claim.

## Lesson two: a probe needs a presence listener

With the rules live, we wanted to see a real join succeed and a wrong one fail. A quick hand-written probe minted a token, joined the private topic, and waited for SUBSCRIBED. It was refused. For a few minutes it looked as if the rules were wrong.

They were right. The probe was wrong, and the reason is in the realtime client's subscribe():

```js
const presence_enabled = (!!this.bindings[REALTIME_LISTEN_TYPES.PRESENCE] &&
    this.bindings[REALTIME_LISTEN_TYPES.PRESENCE].length > 0) ||
    ((_c = this.params.config.presence) === null || _c === void 0 ? void 0 : _c.enabled) === true;
// ...
presence: Object.assign(Object.assign({}, presence), { enabled: presence_enabled }),
```

Passing a presence key in the channel config does not turn presence on. The client turns it on only when the channel has at least one presence listener registered, or when the config sets enabled: true explicitly. Our probe registered no listener, so its join asked for a channel with presence off. Our rules only ever admit presence, so nothing matched and the server correctly said no. The bar itself always registers its sync listener before it calls subscribe(), which is why it joined and the probe did not.

The lesson: probe with the client exactly as the product uses it. We already had a test that does. It drives the real supabase-js and realtime-js client with only the WebSocket swapped for a recorder, and it pins what Realtime actually receives: the socket URL, private: true, the minted token rather than the anon key, and a renewal sent to the joined channel with no second join. The hand-written probe skipped all that, and misled us within minutes.

## What we take from it

- A graceful fallback that looks like the normal state will hide a failure for as long as you let it. If a feature can quietly degrade, something other than a person's eyes has to notice when it does.
- When a security control breaks a feature, read the feature before you widen the control. Here the control had been covering for a design that should never have shipped public.
- Put the access decision in one place and reuse it. The token route calls the admin layout's own guard, so the two cannot drift apart.
- Migrations that touch someone else's schema should check, not change. Run them as one transaction, so a failure leaves nothing behind.
- Test through the real client. Some of what a realtime join asks the server for is decided by the library before a byte is sent.
