A live commerce poll has an awkward constraint: an RTC room token carries identity and permissions, but it cannot prove that a shopper is still present. That distinction, explained in practical terms below, decides whether each vote can be tied to a participant who is actually in the session. Presence must come from the live connection lifecycle.
TL;DR: Put a stable participant subject, room scope, intended audience, allowed actions, issue time, expiry time, and a unique token identifier in the signed credential. Keep cart data, display names, poll answers, and current online status out. Verify the token on a trusted service, bind each accepted connection to a server-side participant record, and calculate presence from authenticated joins, disconnects, and timeouts. The token starts the session; it is not the session.
That boundary is the design. Miss it and a perfectly valid signature can still produce duplicate voters, stale audience counts, or a moderator credential wandering into the wrong sale.
What does an RTC room token carry for identity and permissions?
Most room tokens are signed claim sets. JSON Web Token terminology is useful even when a system uses another envelope: iss identifies the issuer, sub identifies the subject, aud identifies the intended recipient, exp limits acceptance, nbf delays acceptance, iat records issuance, and jti gives the token a unique identifier. RFC 7519 defines those registered claims. It does not prescribe room membership, media roles, or application permissions. Those are application claims.
For a live poll, I would model five concerns: subject, room, audience, capabilities, and lifetime. The exact field names matter less than their validation rules. A useful payload might describe participant shopper_8f2, room sale_104, audience rtc-gateway, capabilities media:subscribe and poll:vote, then expire shortly after the expected admission window.
The signature protects integrity and authenticates the issuer under the verifier's key policy. It does not encrypt the payload. Anyone holding a typical compact JWT can base64url-decode its header and claims, so customer email, cart contents, address, and private profile data do not belong there. TLS protects transport. It does not turn a bearer credential into a safe database row.
WebRTC itself does not define this room token. The W3C WebRTC API covers peer connections and media or data exchange in the browser; application signaling and authorization sit outside that specification. Two services can both use WebRTC while assigning different meanings to custom claims.
Why can't the token be the presence record?
Authorization and liveness answer different questions.
A token says, "this subject may attempt this action until this time." Presence says, "the server currently has an accepted, live session for this participant in this room." A shopper can close a laptop while the token remains valid. A mobile client can reconnect twice after a network switch. Two tabs can present the same bearer token. None of those events changes the signed claims.
Treating token validity as presence inflates the audience. It also makes cleanup wait for credential expiry, which may be minutes after the underlying transport disappeared. For a poll that closes in 30 seconds, that lag is the product bug users notice: the UI says 412 people are present, but only 367 live connections can receive the question. Those figures are an example test case, not a production benchmark.
There is another trap. The token subject should represent the account or anonymous session consistently, but a connection needs its own identifier. Reusing sub as the connection key means a reconnect can overwrite the old socket before its delayed disconnect event arrives. The late event then deletes the new connection. Small race. Ugly result. Use a distinct server-generated connection ID and make disconnect processing conditional on that exact ID.
The clean flow is:
- An application backend authenticates the shopper and issues a narrow, short-lived credential for one room.
- The realtime gateway verifies the approved algorithm, key, issuer, audience, timestamps, room, and capability before accepting the join.
- The gateway creates a record keyed by a fresh connection ID and associates it with the stable subject.
- Heartbeats and transport events update that record. A grace interval absorbs brief reconnects.
- The poll service accepts a vote only when the subject has
poll:vote, belongs to the poll's room, and satisfies the chosen live-presence rule.
Short-lived tokens reduce the replay window, but they do not eliminate replay. Single-use admission, a jti replay cache, or a one-active-vote rule may be warranted depending on the threat model. RFC 8725 is blunt about the verifier's job: pin acceptable algorithms, validate issuer and audience, and avoid trusting claims before cryptographic validation.
The smallest working boundary
This TypeScript keeps issuance behind an interface and makes verification policy explicit. The signing primitive is intentionally abstract; production code should use a maintained JOSE implementation rather than hand-written cryptography. Every acceptance check belongs on the trusted side.
type Capability = "media:subscribe" | "poll:vote" | "poll:moderate"; type RoomClaims = { iss: string; sub: string; aud: string; roomId: string; capabilities: Capability[]; iat: number; exp: number; jti: string; }; type VerifiedJoin = { subjectId: string; roomId: string; capabilities: ReadonlySet<Capability>; tokenId: string; }; interface TokenVerifier { verify( compactToken: string, policy: { issuer: string; audience: string; algorithms: readonly string[] } ): Promise<RoomClaims>; } async function authorizeJoin( verifier: TokenVerifier, token: string, requestedRoomId: string, nowSeconds: number ): Promise<VerifiedJoin> { const claims = await verifier.verify(token, { issuer: "https://auth.example.test", audience: "rtc-gateway", algorithms: ["EdDSA"] }); if (claims.exp <= nowSeconds) throw new Error("Token expired"); if (claims.iat > nowSeconds + 30) throw new Error("Invalid issue time"); if (claims.roomId !== requestedRoomId) throw new Error("Wrong room"); if (!claims.capabilities.includes("poll:vote")) { throw new Error("Voting is not allowed"); } return { subjectId: claims.sub, roomId: claims.roomId, capabilities: new Set(claims.capabilities), tokenId: claims.jti }; } The 30-second clock-skew allowance is an example policy, not a universal constant. Benchmark it against actual clock discipline and the request path. A wide allowance makes deployment forgiving but extends the period in which a future-dated credential may pass an application-level check. The JOSE verifier must still perform full signature and registered-claim validation; this function adds room-specific rules.
Do not let the browser choose roomId and then copy it into a newly minted token without checking entitlement. Do not accept alg because the incoming header asks nicely. Do not log the compact token. Those are three different mistakes with the same smell: untrusted input crossed the boundary without a narrow policy.
For vote deduplication, choose the product rule first. If one account gets one vote, key the accepted vote by (pollId, subjectId), not by connection ID or token ID. Reconnects and token refreshes then remain harmless. If anonymous guests may vote, issue a stable anonymous subject from a server-controlled session and document how cookie loss affects the count. No cryptographic trick can recover an identity the product never established.
Measure presence before choosing the machinery
I care less about an "online" badge than its state transitions. Can the application distinguish authenticated, connected, reconnecting, and disconnected? Can it attach a stable subject to multiple connections? Can it reject a stale disconnect? Can operators see why a join failed without exposing the credential?
A small test harness should exercise those semantics with fixed scenarios. Open two connections for one subject. Drop one without a clean close. Refresh a token during a reconnect. Deliver disconnect events out of order. Attempt a vote after the presence grace interval. Record expected and observed counts at each step. Benchmark the path users feel: token request to accepted join, reconnect to restored presence, and poll publication to eligible-voter receipt. Report percentiles and failure counts, not one warm local average. Keep the data set explicit, such as 500 participants, 25 reconnecting clients, and 50 duplicated tabs. Those values describe test load, not production capacity. Presence accuracy also needs a denominator: compare unique eligible subjects in the server's active connection registry with unique subjects allowed to vote. Raw socket count is wrong when shoppers open multiple tabs, while token count is worse because issued credentials can remain unused. Observability should then separate signature rejection, issuer or audience mismatch, expiry, room mismatch, missing capability, replay decision, and transport loss. Log jti only if its handling policy permits correlation, and prefer a one-way derived value. Never log the bearer token or sensitive claims. Metrics need bounded labels; room and subject identifiers belong in controlled traces or logs, not high-cardinality metric labels.
Measure it.
What I would change at scale
The minimal design keeps connection state in one trusted process. At scale, I would move presence to a partitioned, ephemeral registry and make every update carry a monotonic session version or connection ID. The goal is deterministic handling of retries and reordered events.
Key rotation becomes operational work. Verifiers need a bounded set of trusted keys, a rollout overlap, cache behavior that does not make every join depend on a remote key fetch, and alarms for unknown key identifiers. Revocation needs an explicit stance. Very short credential lifetimes may be enough for routine capability changes; urgent account suspension can require a denylist or a server-side session check. That adds coordination and latency. Measure both.
I would also split moderation from voting. A moderator token should have a separate capability and usually a separate issuance path, because accidental privilege inheritance is easier when one broad role string controls media publication, poll administration, and room removal. Capability arrays are not magic, but they make authorization checks local and reviewable.
The trade-off is config surface. Every extra issuer, audience, role mapping, clock tolerance, refresh threshold, and revocation store creates another way for staging and production to disagree. Keep one documented policy object, validate it at startup, and test it with known-good and known-bad tokens. Time-to-first-call matters. Predictable call number ten thousand matters more.
Config compounds.
Ship the boundary, not the bearer string
A room token should carry the minimum stable facts needed to authorize admission and actions: who the subject is, which room and verifier it targets, what it permits, when it is valid, and an identifier for replay or audit policy. It should not carry mutable commerce state or claim that somebody is online.
For a live poll, accuracy comes from joining that verified authorization to a server-owned connection lifecycle, then deduplicating votes by the product's identity rule. Keep those systems separate. Test reconnect races before load tests make them harder to see.
