Short answer: For a property-management video call, start with one server-issued credential scoped to one appointment room and one participant identity. If someone cannot join, compare the room and identity the server authorized with the room and identity the client actually attempted. Then separate admission failure from a connection that was admitted but never established. Those are different investigations.
| Symptom at the boundary | First comparison | Pick this when |
|---|---|---|
| Join rejected before signaling | Authorized room and participant versus requested room and participant | The client has no session to reconnect |
| Join accepted, media never arrives | Signaling state, ICE state, and candidate exchange | Admission succeeded but transport did not |
| Return after a network drop creates a second presence | Stable participant identity, current credential, and room membership | A reconnect looks like a new attendee |
Why can't a participant join the video room after reconnecting?
Picture a tenant joining a maintenance walkthrough from a phone while the property manager waits in a room keyed to appointment visit-4821. The tenant's display name is useful UI data. It is not an authorization identity. The invitation URL is a room locator, not proof that its bearer may enter; handing the browser an arbitrary room name and asking it to mint its own permission makes the browser the authority over its own access.
A server can instead bind the authenticated tenant's internal subject, appointment, and allowed room to a short-lived join credential. That binding is an application design choice, not something WebRTC specifies. On each join attempt the admission layer checks the credential, the requested room, and the authenticated subject before starting or resuming a session. If the authorized room is visit-4821 but the browser requests visit-4812, stop there. No amount of ICE tuning fixes a scope mismatch.
Check the exact strings.
Don't log the credential itself. Record a request correlation ID, a non-secret room identifier or stable hash, the authenticated subject identifier under your retention policy, the decision category, and the credential's expiry boundary. Keep the detailed authorization result on the server. A client-facing rejection can remain generic so that someone probing room names learns nothing about membership.
Pick narrow admission for scheduled visits
For an appointment-bound room, the easiest rule to audit is one participant identity, one visit room, and a credential that expires. A reconnect may reuse the identity while obtaining a fresh credential if the earlier one has expired. The server must still decide whether the appointment remains active and the participant remains eligible. Expiry is a boundary, not a promise that an already connected media session will end at the same instant; enforce ongoing access in session policy if that is required. In particular, the display name can change without changing the authenticated subject. A manager who edits a tenant's displayed name should not inadvertently create a new authorization principal, and a second tab using the same display name should not inherit the first tab's connection attempt ID.
There is a trade-off. Narrow scope means a transferred visit or changed appointment room requires the server to issue new authorization. That is good friction when a property manager can accidentally paste the wrong invitation into a tenant thread. It is extra work if the workflow intentionally moves a caller among rooms. In that case, model the permitted transitions on the server and log each transition; do not make the client choose its own wider scope.
A successful admission event does not imply working video. WebRTC exposes connectionState and iceConnectionState on RTCPeerConnection; these are transport signals, not a verdict about who was allowed into an application room. The W3C specification also defines getStats(), which can support a later transport investigation. First draw the boundary: server admission decision, signaling exchange, ICE connectivity, then media. This diagram in words gives every on-call engineer the same starting point.
Different boundary, different alert.
On a dropped mobile connection, preserve the logical participant identity for the appointment but create a new attempt ID for the new connection. Correlate it with the previous attempt ID. Otherwise two tabs, a retry, and a legitimate reconnect all look like the same event, and an alert for duplicate presence becomes noise. Treat a credential rejection as an admission alert and an ICE failure after admission as a connectivity alert. Count both by room and attempt, with low-cardinality reason categories; don't put raw credentials, arbitrary URLs, or unbounded room IDs into metric labels.
A minimal admission and telemetry boundary
Here is an application-side sketch. verifyJoinCredential and authorizeVisit are server-owned interfaces; their implementation depends on your authentication system. The important detail is the order of checks and the fact that the browser's requested room never widens the grant. Return a fresh attempt ID for each accepted connection, even when the participant returns with the same stable identity.
type JoinGrant = { subjectId: string; roomId: string; expiresAtMs: number };
type JoinRequest = { credential: string; roomId: string; attemptId: string };
type JoinDecision =
| { ok: true; subjectId: string; roomId: string; attemptId: string }
| { ok: false; reason: "expired" | "scope_mismatch" | "not_allowed" };
async function admitJoin(
request: JoinRequest,
nowMs: number,
verifyJoinCredential: (token: string) => Promise<JoinGrant | null>,
authorizeVisit: (subjectId: string, roomId: string) => Promise<boolean>,
record: (event: { attemptId: string; outcome: string }) => void,
): Promise<JoinDecision> {
const grant = await verifyJoinCredential(request.credential);
let reason: "expired" | "scope_mismatch" | "not_allowed" | null = null;
if (!grant || grant.expiresAtMs <= nowMs) reason = "expired";
else if (grant.roomId !== request.roomId) reason = "scope_mismatch";
else if (!(await authorizeVisit(grant.subjectId, grant.roomId))) reason = "not_allowed";
record({ attemptId: request.attemptId, outcome: reason ?? "accepted" });
if (reason) return { ok: false, reason };
return { ok: true, subjectId: grant!.subjectId, roomId: grant!.roomId, attemptId: request.attemptId };
}
In production, generate and validate attempt IDs at the trusted edge too; the sketch accepts one solely to show correlation across layers. Test an expired grant, a one-character room mismatch, revoked appointment access, two simultaneous tabs, and a reconnect with a fresh credential. Assert that rejection produces no signaling session, and that accepted joins retain the same logical participant while receiving distinct attempt IDs. Deploy the counters before changing admission rules, so a rise in scope mismatches is visible without inspecting private call data.
Limits worth keeping visible
This boundary identifies where to investigate; it does not implement signaling, TURN, or media delivery. If admission is accepted but ICE cannot establish connectivity, investigate candidate gathering and connectivity with the browser's connection state and stats. If admission is rejected, start with authorization records. Keep those paths separate.
References
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Further reading
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)