Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

How to Explain What RTC Room Tokens Carry (Identity and Permissions)

Use a short-lived, signed capability for room admission, then enforce every media and moderation action on the server. Do not treat a token as a promise that a permission update reached every participant. Choice Vali

Use a short-lived, signed capability for room admission, then enforce every media and moderation action on the server. Do not treat a token as a promise that a permission update reached every participant.

Choice Validation path Revocation behavior Fan-out consequence
Signed capability Local signature and claim checks Usually valid until expiry No authorization-store dependency at join; changes still need a control path
Opaque session ID Lookup on each admission or action Immediate when the shared record changes Strong consistency depends on the store and its availability
Backend proxy for every action Application server decides inline Immediate at the decision point Simplest policy authority, but every operation crosses that service

Recommendation: mint a signed room token on the application server, keep its lifetime deliberately short, and use an ordered server-side control stream for changes after admission. This is the least complex option that keeps browser input out of the trust boundary while avoiding a database read for every join. For a one-person SaaS, fewer synchronous dependencies buy back hours that can go into the weekly release. I ship weekly, so my rule is to outsource undifferentiated cryptography to a maintained library and own only the policy. The trade-off is explicit: revocation latency is bounded by token lifetime unless the room service also consults live state.

What should a room token actually carry?

A room token is an application credential, not part of WebRTC itself. The WebRTC specification defines browser APIs and the processing model for peer connections; it does not define room membership, user roles, or a room-token format. Those belong to the signaling and application layer.

For a video room, the useful claims are small:

  • iss: which backend minted the credential.
  • aud: which room service may accept it.
  • sub: a stable account or guest identity. This is not a display name.
  • room: the exact room scope, such as demo-4821.
  • grants: narrow actions such as join, publishAudio, or publishVideo.
  • iat and exp: issuance and expiration times.
  • jti: a unique identifier for audit correlation or targeted denial.

Keep mutable presentation data outside the authority decision. A browser may ask to display “Maya,” but the server should derive sub, room, and grants from authenticated application state. It should never sign arbitrary claims submitted by that browser.

The distinction matters. Identity answers who is acting. Permissions answer what that identity may do in this room. Room scope answers where. Mixing those fields into one free-form role such as host makes later policy changes harder: does a host get camera access, screen sharing, removal rights, recording control, or all four? Explicit grants leave less room for accidental privilege.

Token payloads also should not contain secrets. A signed payload is protected against modification, but signature alone does not make its contents confidential. RFC 7519 defines the registered JWT claims above, while RFC 8725 documents common implementation hazards and calls for explicit algorithm verification and audience validation.

Why doesn't a valid token guarantee delivery?

Validation proves that an accepted credential was issued by a trusted authority, is intended for this service, is within its time window, and grants the requested action. It says nothing about whether all participants received a later mute, role-change, or removal event.

That gap becomes visible at fan-out. Suppose participant user-17 joins with publishAudio, and an operator removes that grant while three room nodes are serving subscribers. A best-effort broadcast can reach two nodes and miss the third. The old token may remain cryptographically valid. Re-sending the same change can also produce duplicates.

Use two separate mechanisms:

  1. Admission validates the signed snapshot and creates server-owned participant state.
  2. Each room policy change gets a monotonically increasing room revision.
  3. Consumers apply a revision once, acknowledge it, and request missing revisions when they detect a gap.
  4. The media server checks current participant state before accepting a publish or moderation action.

This gives the control plane an at-least-once shape without applying a duplicated permission change twice. A newly connected node reads the current revision before serving the room. If strict, immediate revocation is required, check a shared deny record or current policy epoch too; short expiry alone cannot provide that guarantee.

Do not promise exactly-once delivery. Across process crashes and network retries, an idempotent handler plus durable ordering is the useful target. The control event can arrive twice. The permission result must be the same.

Implement the narrow contract

The following TypeScript uses Node's standard crypto module so the trust boundary is visible. It is a compact teaching implementation, not a new token standard. A deployed system should use a maintained JOSE library, pin the accepted algorithm, protect signing keys, and rotate them with a key identifier.

The values 90 seconds and 15 seconds below are operating choices, not universal constants. They make the stale-authorization window concrete enough to test.

import {
  createHmac,
  randomUUID,
  timingSafeEqual,
} from "node:crypto";

type Grant = "join" | "publishAudio" | "publishVideo" | "moderate";

type RoomClaims = {
  iss: "app-backend";
  aud: "room-service";
  sub: string;
  room: string;
  grants: Grant[];
  iat: number;
  exp: number;
  jti: string;
};

const encode = (value: unknown): string =>
  Buffer.from(JSON.stringify(value)).toString("base64url");

const sign = (input: string, secret: Buffer): string =>
  createHmac("sha256", secret).update(input).digest("base64url");

export function mintRoomToken(
  input: Pick<RoomClaims, "sub" | "room" | "grants">,
  secret: Buffer,
  nowSeconds = Math.floor(Date.now() / 1000),
): string {
  if (secret.length < 32) throw new Error("Signing secret must be at least 32 bytes");
  if (!input.sub || !input.room || !input.grants.includes("join")) {
    throw new Error("Identity, room, and join grant are required");
  }

  const header = encode({ alg: "HS256", typ: "JWT" });
  const claims: RoomClaims = {
    iss: "app-backend",
    aud: "room-service",
    sub: input.sub,
    room: input.room,
    grants: [...new Set(input.grants)],
    iat: nowSeconds,
    exp: nowSeconds + 90,
    jti: randomUUID(),
  };
  const body = encode(claims);
  const signingInput = `${header}.${body}`;
  return `${signingInput}.${sign(signingInput, secret)}`;
}

export function authorize(
  token: string,
  expected: { room: string; grant: Grant },
  secret: Buffer,
  nowSeconds = Math.floor(Date.now() / 1000),
): RoomClaims {
  const parts = token.split(".");
  if (parts.length !== 3) throw new Error("Malformed token");
  const [encodedHeader, encodedClaims, suppliedSignature] = parts;
  const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8"));
  if (header.alg !== "HS256" || header.typ !== "JWT") throw new Error("Rejected header");

  const expectedSignature = sign(`${encodedHeader}.${encodedClaims}`, secret);
  const supplied = Buffer.from(suppliedSignature, "base64url");
  const wanted = Buffer.from(expectedSignature, "base64url");
  if (supplied.length !== wanted.length || !timingSafeEqual(supplied, wanted)) {
    throw new Error("Bad signature");
  }

  const claims = JSON.parse(
    Buffer.from(encodedClaims, "base64url").toString("utf8"),
  ) as RoomClaims;
  const clockSkewSeconds = 15;
  if (claims.iss !== "app-backend" || claims.aud !== "room-service") {
    throw new Error("Wrong issuer or audience");
  }
  if (claims.iat > nowSeconds + clockSkewSeconds || claims.exp <= nowSeconds - clockSkewSeconds) {
    throw new Error("Token is outside its accepted time window");
  }
  if (claims.room !== expected.room || !claims.grants.includes(expected.grant)) {
    throw new Error("Room or grant denied");
  }
  return claims;
}

The verifier takes the expected room and action from the server's request context. That is intentional. If code merely decodes a token and trusts whichever room appears inside it, a token can be replayed against a handler that failed to bind its own route or connection to the claim.

Fail closed. Reject an unknown algorithm, wrong audience, missing grant, expired credential, malformed value, or mismatched room before allocating media resources. Log the jti, sub, room, decision, policy revision, and a reason code, but never log the full credential.

Test the failures before shipping

Happy-path tests are cheap and insufficient. The small suite below checks the two boundaries most likely to get blurred: room scope and expiration.

import { randomBytes } from "node:crypto";
import { strict as assert } from "node:assert";

const secret = randomBytes(32);
const issuedAt = 1_800_000_000;
const token = mintRoomToken(
  {
    sub: "user-17",
    room: "demo-4821",
    grants: ["join", "publishAudio"],
  },
  secret,
  issuedAt,
);

const claims = authorize(
  token,
  { room: "demo-4821", grant: "publishAudio" },
  secret,
  issuedAt + 30,
);
assert.equal(claims.sub, "user-17");

assert.throws(() =>
  authorize(token, { room: "other-room", grant: "join" }, secret, issuedAt + 30),
);
assert.throws(() =>
  authorize(token, { room: "demo-4821", grant: "moderate" }, secret, issuedAt + 30),
);
assert.throws(() =>
  authorize(token, { room: "demo-4821", grant: "join" }, secret, issuedAt + 106),
);

Add tests for a changed signature, wrong issuer, wrong audience, future iat, duplicate control delivery, and a skipped policy revision. Then exercise the real topology: disconnect one room node during a permission update, reconnect it, and verify that it catches up before accepting another publish. This is where a design that looks correct in a unit test often reveals its actual delivery contract.

Observability should answer one operational question quickly: why was this action allowed? A decision log needs the policy revision as well as token metadata, because the same valid token can produce a different result after a live permission change. Track control-event lag and revision gaps separately from token validation failures. They are different failure domains.

When is the runner-up better?

The main limitation of a signed capability is stale authority. It is not suitable when every revocation must take effect immediately across every node, because a correctly signed token can remain acceptable until its expiration or a live deny check overrides it.

Choose opaque sessions when immediate central revocation matters more than admission independence. A sensitive support room, for example, may require every action to reflect the latest account state. The drawback is a deliberate availability and latency dependency, so define what happens when the store cannot be reached. Granting access on lookup failure quietly defeats the reason for choosing this model.

A backend proxy is reasonable when the action rate is low and policy changes constantly. Moderation commands fit better than high-frequency media operations. The proxy keeps policy in one place, at the expense of another network hop and a larger failure surface. Its limitation is throughput: putting every media-adjacent action on that path makes the proxy a scaling and availability dependency.

For the common developer-tools demo room, the signed capability remains the practical default: one credential for admission, narrow grants, a short validity window, and server-owned live state. It is easy to operate and easy to explain. More important, its limits are visible. The token authenticates an authorization snapshot; ordered, retryable control events carry later changes across the fan-out.

Further reading

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.