Dev Investigation Notes
A WebSocket room host loses control after leaving and rejoining — restoring it with a localStorage token and a server-side hash
This is a translated article. Read the original in Korean ↗
We were moving formation, a web app where one person (the leader) plays a video in a dance rehearsal studio while everyone else follows the same point in the video and formation on their own phones, to a WebSocket room architecture on Cloudflare Workers. Creating rooms, joining them, and attaching choreography data all worked locally, but once the leader left a room, there was no way to return as the leader . The room stayed alive with nobody able to control it. This article uses plain WebSockets on Cloudflare Durable Objects, but the principle of "keep the token in the browser, store only its hash on the server, and compare on reconnect" applies just as well to other real-time servers such as socket.io.
Conclusion
Define the host as "the person holding the secret token," not "the connection that created the room." Then build a UI entry point for returning with that token.
- When creating a room, the client generates a random token, stores it in localStorage, and sends only its hash to the server
- The leader page sends the token when connecting or reconnecting. The server compares hashes: a match grants the leader role; a missing or incorrect token downgrades the connection to a viewer
- Never include the hash in room listings or responses
- In the lobby, rooms with a token in localStorage (= rooms I created) get a "리더로 재입장" (Rejoin as leader) button. Other rooms get a "참여" (Join) button
- The server distinguishes "disconnection" (keep the room) from "explicit closure" (delete the room). If disconnecting means closing, there is no room to return to
The following is a generic implementation example, since no code remains in the session record. The session confirms only that hashing uses crypto.subtle.digest . The key name, token length, and where the token is passed are illustrative.
// Client — when creating a room (generic implementation example)
const token = [...crypto.getRandomValues(new Uint8Array(32))]
.map(b => b.toString(16).padStart(2, '0')).join('');
async function sha256Hex(s) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}
localStorage.setItem(`leaderToken:${roomId}`, token); // Original stays in the browser only
await createRoom({ name, leaderTokenHash: await sha256Hex(token) }); // Only the hash goes to the server
// Server (room object) — on connection (generic implementation example)
const presented = url.searchParams.get('token');
const role = presented && (await sha256Hex(presented)) === this.leaderTokenHash
? 'leader' : 'viewer'; // On mismatch, downgrade instead of disconnecting
Situation
- Architecture: browser ↔ Worker ↔ one Lobby object (active room list) + one Room object per room (real-time state and WebSocket relay). This used Cloudflare Durable Objects
- When the leader sends play, pause, or seek commands, the Room broadcasts them to all viewers. Video files stay on each person's phone; the Room stores only the choreography data (formation.json)
- Room lifetime: the room persists even if the leader disconnects, and an alarm deletes it after one hour without any control activity. If the leader sends
close, the room is deleted immediately and the viewer sockets are closed too
Symptom
- After verifying room creation, joining, and choreography attachments locally: "It looks like there's no way for the leader to leave the room and then rejoin as the leader"
- The lobby's "참여" (Join) button always led to the viewer page (
viewer.html). A leader reconnect URL (leader.html?room=코드) existed, but there was no UI path to it - Since the room was designed to survive disconnections, the result was "a live room that nobody can control." The initial design's state table already included a "leader reconnect" entry
Cause
Two things were missing at the same time.
- There was no way to prove authority. The leader role belonged to the connection that originally created the room, and a new connection had no way to prove "I'm that leader." Adding tokens also put "prevent just anyone from becoming the leader" on the fix list
- There was no entry point for returning. The server-side URL existed, but the lobby had no way to recognize "a room I created," so there was no button either. The token left in the browser is the lobby's only clue
The initial design table included this feature as "reconnect within 1h with leaderToken → regain leader privileges" . But after the server relay was built, "leaderToken reconnection" was postponed to "Phase 4" , and the next summary described it as "(optional — for now, continue by reselecting the video on reconnect)" . While a feature from the design was deferred twice, the person testing it found the gap first.
Fix
One commit changed five places .
| Location | Change |
|---|---|
Transport wrapper (live.js) | Added token generation and hashing utilities |
| Leader page | Generate and store a token when creating a room, present it on connection, and handle role rejection (downgrade to viewer) |
| Lobby object | Accept and store the token hash, but omit it from public listings |
| Room object | Validate the leader token, downgrade to viewer on mismatch, and never send the hash to clients |
| Lobby | Rooms with a token get "리더로 재입장" (Rejoin as leader) (→ leader.html); rooms without one get "참여" (Join) |
Verification:
- Locally: correct token → leader; incorrect or missing token → downgrade to viewer; hash not exposed in listings or responses . The verification script initially failed because the lobby page hadn't loaded
live.js, so the helpers were inlined and the script was run again - After deployment (https): token hashing and room creation, followed by a leader connection over wss → confirmed
role leader - There is no recorded result of tapping "리더로 재입장" (Rejoin as leader) on a physical device. It appears only on the test list
Even after rejoining, you have to select the video file again. The file lives on each person's device, not on the server .
Bonus — token hashing only works over https (or localhost)
crypto.subtle exists only in secure contexts (https or localhost). The conclusion was that accessing a LAN IP such as http://<PC IP>:8787 from a phone causes token hashing (and video fingerprinting) to fail, preventing room creation, so phone testing would use the https deployment URL . After deployment, secure context: true, crypto.subtle: true was confirmed .
An apparent limitation of this design (not discussed in the session): since the token exists only in one browser's localStorage, you cannot return as the leader from another device, another browser, an incognito window, or after clearing storage.
Reporter's Notes — the URL existed, but there was no door
leader.html?room=코드 has always been there. Go straight there and you're the leaderrole leader with a token at the deployment URL crypto.subtle, which we use for hashing, only exists on https or localhost, so accessing an IP over http blocks you right at room creation