Dev.to · 7 min read

Node.js Video Room Tokens: Express Middleware for Scoped Participant Access

Node.js Video Room Tokens: Express Middleware for Scoped Participant Access

Use a short-lived, room-scoped participant token minted by your server. Keep room creation, role selection, and cursor permissions behind an authenticated Express endpoint. Let the browser carry media and cursor traffic, but never let it choose its own claims. Short answer: create the room in your application database, derive permissions from the signed-in user and course enrollment, then issue a token that names one room, one participant, and an expiry of minutes rather than days. The media service or WebRTC gateway verifies that token. Your editor still treats cursor updates as untrusted input. Decision note: which trust boundary fits a one-person SaaS? Boundary Token issued by Browser can influence Best fit Main cost Application server Your API after session and enrollment checks Display name and device metadata only Classroom rooms tied to an existing account You own key rotation and audit trails Dedicated identity service A separate authorization component A narrowly defined scope request Multiple apps sharing one identity policy More deployment and incident surface Client-created room The browser Room id, role, and lifetime Prototypes with no protected content A client can impersonate a participant For an education editor, use the first row. It has the fewest moving parts and keeps the important decision in one place: the application knows whether Alice is enrolled in the lesson before it grants access. That is a revenue-per-hour decision for a solo SaaS. Outsource the undifferentiated media plumbing, but keep authorization in code you can inspect. The runner-up, a separate identity service, becomes reasonable when several products must share policy or when a security team owns key management. It does not make a browser trustworthy by itself; it only moves the signing decision. What should a participant token prove, and how do you mint it? A token should answer four questions without consulting browser state: who is this participant, which room can they join, what can they do, and when does the grant expire? JWT registered claims give you standard names for issuer, subject, audience, issued-at time, and expiry. Your application-specific claims can carry a room identifier and a small permission set. Keep the scope narrow. A token for lesson-8/room-42 should not work for lesson-9/room-7. Use an opaque room id, not a student email, in URLs and claims. The server can map that id to a course and tenancy record. I first thought a valid signature was enough. It is not. A correctly signed token with a broad rooms:* permission still hands too much authority to a stolen browser session. The useful control is the intersection of identity, room, and action. A practical policy for the example below is a 10-minute lifetime, a 30-second clock-skew allowance at verification, and separate permissions for media:publish, media:subscribe, and cursor:write. These values are policy knobs, not protocol requirements. Short lifetimes reduce replay value; they also mean reconnect code must fetch a fresh token. The route below shows the shape, with a generic room store and a JWT signer. The enrollment lookup is the trust boundary. It runs before any token is signed. import express, { Request, Response, NextFunction } from 'express'; import { SignJWT } from 'jose'; const app = express(); app.use(express.json()); const signingKey = new TextEncoder().encode(process.env.PARTICIPANT_TOKEN_KEY); type Permission = 'media:publish' | 'media:subscribe' | 'cursor:write'; interface SessionRequest extends Request { user?: { id: string }; } async function requireSession(req: SessionRequest, res: Response, next: NextFunction) { const user = await sessionFromCookie(req); if (!user) return res.status(401).json({ error: 'unauthorized' }); req.user = { id: user.id }; next(); } app.post('/rooms', requireSession, async (req: SessionRequest, res: Response) => { const lessonId = String(req.body.lessonId ?? ''); const membership = await enrollmentFor(req.user!.id, lessonId); if (!membership) return res.status(403).json({ error: 'not_enrolled' }); const room = await roomStore.create({ lessonId, ownerId: req.user!.id }); res.status(201).json({ roomId: room.id }); }); app.post('/rooms/:roomId/participant-token', requireSession, async (req: SessionRequest, res: Response) => { const room = await roomStore.find(req.params.roomId); if (!room) return res.status(404).json({ error: 'room_not_found' }); const membership = await enrollmentFor(req.user!.id, room.lessonId); if (!membership) return res.status(403).json({ error: 'not_enrolled' }); const permissions: Permission[] = membership.role === 'teacher' ? ['media:publish', 'media:subscribe', 'cursor:write'] : ['media:publish', 'media:subscribe', 'cursor:write']; const token = await new SignJWT({ room: room.id, permissions, }) .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) .setIssuer('classroom-api') .setAudience('video-gateway') .setSubject(req.user!.id) .setIssuedAt() .setExpirationTime('10m') .sign(signingKey); res.json({ token, expiresInSeconds: 600 }); }); async function sessionFromCookie(req: Request): Promise { return null; // Replace with the application's session lookup. } async function enrollmentFor(userId: string, lessonId: string): Promise { return null; // Read from the course database, never from request.body.role. } const roomStore = { async create(input: { lessonId: string; ownerId: string }) { return { id: crypto.randomUUID(), ...input }; }, async find(id: string) { return { id, lessonId: 'lesson-8' }; }, }; The two placeholder functions are deliberate seams: session cookies and enrollment data belong to your application. The token route should not accept a requested role, participant id, or permission array from the client. Validate lessonId and roomId against your own identifiers, cap request body size, and log denials without logging the token itself. Verification happens at the media or signaling boundary. Check the signature, issuer, audience, expiry, and room claim. Then enforce each action: publishing audio is different from writing a cursor. A valid token is authentication material, not a blanket authorization decision. No client-supplied field gets a vote. Where does collaborative cursor sync fit? Video and cursor traffic can share a connection, but they have different correctness rules. WebRTC data channels can carry small, transient cursor messages. A server-mediated WebSocket can be easier to observe and replay for an editor. Pick based on recovery needs, not novelty. Cursor updates are hints about the latest pointer position. Include a monotonically increasing sequence per participant and discard older sequences. On reconnect, request a fresh token and send a snapshot of active participants; do not attempt to replay every mousemove. A 20 Hz client throttle is a reasonable starting point for a classroom editor, then tune it from measured event loss and CPU use. Treat labels, colors, and coordinates as hostile input. Clamp coordinates to the document bounds, cap label length, and rate-limit messages per participant. The token's cursor:write permission says who may send; validation still decides what the message may contain. Observability should follow the same boundary. Record room id, participant id, token key id, denial reason, and latency buckets. Redact bearer values. A useful alert is a spike in expired-token reconnects, which often means the client forgot to refresh before the 10-minute window. Known limitations: this server-minted pattern is not suitable for an anonymous, disposable demo where impersonation has no consequence and the extra session and enrollment lookup would delay the first experiment. A client-created room has less setup friction there. The trade-off is clear: a separate identity service is a better fit when mobile, web, and an embedded classroom player must share one policy, or when a dedicated team must rotate keys. For graded lessons, private recordings, or teacher-only cursor layers, keep room creation and participant minting on the application server. The extra database lookup costs a few milliseconds and buys an auditable decision. That trade is usually cheaper than debugging a permission leak after launch. That boundary is deliberately boring. Ship the smallest path first: authenticated room creation, a five-to-fifteen-minute token, explicit action permissions, and reconnect tests. Add regional routing, key sets, and richer presence only when telemetry shows the need. Weekly shipping works when the trust boundary stays boring and explicit. Further reading https://www.w3.org/TR/webrtc/ https://www.rfc-editor.org/rfc/rfc7519 https://expressjs.com/en/guide/using-middleware.html https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Applications_Cheat_Sheet.html

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News