Short answer: issue short-lived RTC tokens from a versioned contract, bind every grant to a class and role, and make presence state expire unless the client renews it. That design gives an online classroom a predictable answer to “who is really here” without putting authorization logic in the browser.
Tokens expire. Presence does too.
I learned this the hard way while reviewing a classroom rollout where a student who had closed a laptop still appeared present for almost ten minutes. The media session had ended, but our application presence row had no expiry and the token payload did not carry a class-generation number. A reconnect from an old tab could therefore look current, and the support operator had no durable way to distinguish a real second device from a browser that had been asleep since the previous lesson. We traced the timeline through login, token minting, the last heartbeat, and the reconnect attempt; the timestamps were all individually plausible, which is why the incident survived a normal happy-path test. The fix was not a different media SDK. It was a stricter data contract and a server-side decision about freshness, plus a test that advanced the clock between every event.
What should an RTC token contract guarantee for an online classroom?
Treat the token as a signed capability, not as a user profile. The issuer should own identity, class scope, role, expiry, and a contract version. The client may present the token to an RTC service, but it should never be able to choose a room or promote itself from student to teacher.
These fields have held up well in capacity reviews:
| Field | Meaning | Validation rule |
|---|---|---|
sub |
Stable account identifier | Derived from the authenticated session |
class_id |
One scheduled class | Must match the requested class and tenant |
role |
teacher, student, or moderator
|
Allow-list, then map to media permissions |
session_id |
Browser/device session | New value on each login or deliberate rejoin |
class_generation |
Revocation boundary | Reject values older than the class record |
exp |
Absolute expiry | Keep short; require refresh before it passes |
contract_version |
Decoder contract | Reject unknown versions rather than guessing |
The useful invariant is simple: a token can grant access only while its class, role, generation, and time window all match server state. A presence indicator is a separate projection. It should be updated by authenticated join/leave events and a heartbeat, then marked stale after a bounded interval. “Connected” is a claim with a clock attached.
How do you issue and validate the contract in Go?
The example below uses a generic signer interface so the policy is visible without tying the article to one vendor. In production, back the interface with a maintained JWT implementation, keep the signing key in a secret manager, and publish key identifiers for rotation. The media provider may have its own token shape; translate from this contract at the boundary.
package token
import (
"errors"
"fmt"
"time"
)
type Claims struct {
Subject string `json:"sub"`
ClassID string `json:"class_id"`
Role string `json:"role"`
SessionID string `json:"session_id"`
ClassGeneration int64 `json:"class_generation"`
ContractVersion int `json:"contract_version"`
IssuedAt time.Time `json:"iat"`
ExpiresAt time.Time `json:"exp"`
}
type Signer interface {
Sign(Claims) (string, error)
}
func Issue(s Signer, now time.Time, subject, classID, role, sessionID string, generation int64) (string, error) {
if subject == "" || classID == "" || sessionID == "" {
return "", errors.New("identity, class, and session are required")
}
if role != "teacher" && role != "student" && role != "moderator" {
return "", fmt.Errorf("unsupported role %q", role)
}
claims := Claims{
Subject: subject, ClassID: classID, Role: role, SessionID: sessionID,
ClassGeneration: generation, ContractVersion: 1,
IssuedAt: now.UTC(), ExpiresAt: now.UTC().Add(10 * time.Minute),
}
return s.Sign(claims)
}
Ten minutes is an example policy, not a universal constant. Pick a lifetime from the recovery requirement: if a teacher is removed, how long may an old tab retain media access? Then test refresh at 80% of the lifetime, with jitter, so a class of 300 browsers does not create a renewal spike on the same second.
Validation must check the signature, issuer, audience, exp, contract version, and class generation. Do not accept a token merely because a library decoded it. A decoded token is untrusted bytes until all those checks pass.
Which presence failure modes should you test before launch?
Presence accuracy is a distributed-systems property, so the test plan needs more than a happy-path join. I use a small state machine: absent -> joining -> present -> stale -> absent. Every transition has an event, a timestamp, and an idempotency key. A duplicate join is harmless; a late leave cannot erase a newer join for the same session.
The ugly cases deserve explicit tests: a laptop sleeps through two heartbeats; a mobile network changes address; a browser opens two tabs; a teacher revokes a student while an audio track is active; the token refresh arrives after the old token expires; and the class is moved to a new generation while a reconnect is in flight. Your expected result should be stated in assertions, not left to an operator's intuition.
package presence
import "time"
type Event struct {
ClassID string
SessionID string
Kind string // join, heartbeat, leave
At time.Time
Sequence uint64
}
type Record struct {
LastSeen time.Time
Sequence uint64
State string
}
func Apply(r Record, e Event, now time.Time) Record {
if e.Sequence <= r.Sequence {
return r // late or duplicated event
}
r.Sequence = e.Sequence
switch e.Kind {
case "join", "heartbeat":
r.State, r.LastSeen = "present", e.At
case "leave":
r.State = "absent"
}
if r.State == "present" && now.Sub(r.LastSeen) > 30*time.Second {
r.State = "stale"
}
return r
}
The 30-second stale window must be measured against your heartbeat interval, mobile tolerance, and classroom UX. I am not sure one number can serve a seminar and a one-to-one tutoring call; measure false-present and false-absent rates separately. A green dot that lingers is misleading, while a dot that flickers every few seconds trains teachers to ignore it.
How do managed RTC services compare with self-hosting for token issuance?
The token contract should stay yours even when media transport is managed. Several services document signed room or meeting tokens, but their claims and permission names differ. LiveKit documents JWT grants for rooms, Daily documents meeting tokens, and Twilio documents access tokens for video. Those are useful adapters, not reasons to leak provider-specific claims into your classroom database.
| Approach | Strength | Cost or risk to plan for |
|---|---|---|
| Managed RTC transport | Faster regional rollout and less media on-call work | Provider claim model, egress limits, and migration effort |
| Self-hosted SFU | Control over topology and data path | Capacity planning, upgrades, TURN operations, and 24/7 response |
| Hybrid boundary | Keep identity and presence portable | Two failure domains and more integration tests |
For a solo platform team, I would keep the issuer and presence ledger provider-neutral, then write one narrow adapter per transport. The decision changes when regulatory residency, custom codecs, or sustained concurrency makes operating an SFU worth the staffing. It is not suitable when nobody can own packet-loss alerts at 02:00. Stick with a managed transport when the operational budget is tighter than the egress bill; self-host when control is the requirement you can actually staff.
What should you measure and rehearse in production?
Set SLOs around user-visible truth: token issuance success, refresh latency, join authorization latency, heartbeat freshness, and the percentage of presence records older than the stale threshold. Track them by class size and region, because a global average hides the class that fails during exam week.
Capacity planning starts with events per second, not concurrent sockets alone. If 10,000 learners send a heartbeat every 15 seconds, the ledger receives about 667 events per second before retries and reconnects. Add a burst factor for the top minute, reserve database write capacity, and load-test token refresh separately from media joins. Keep an audit record for issuer decision, class generation, role, and denial reason; never log raw bearer tokens.
Rehearse key rotation, class-generation revocation, a stale presence sweep, and a provider outage in staging. The fallback should fail closed for new authorization while preserving already-audited state for support staff. That trade-off is uncomfortable, but showing a student as present after access was revoked is worse than asking them to rejoin.
The contract is the durable part. RTC vendors, codecs, and topology can change behind it, while the classroom still gets a defensible answer about who may join and who was last seen.
Top comments (0)