Classroom Authorization: Realtime Revocation Contracts for Reconnect and Presence Races
Realtime access revocation in an online classroom should use data contracts whose authorization epoch is checked on every privileged action, especially after a reconnect. A connection can look healthy while its permissio
Realtime access revocation in an online classroom should use data contracts whose authorization epoch is checked on every privileged action, especially after a reconnect. A connection can look healthy while its permission is already obsolete; the deciding constraint is therefore presence accuracy at the instant a student publishes, subscribes, chats, or rejoins.
TL;DR
Use a server-issued access contract containing a classroom ID, subject ID, monotonically increasing authorization epoch, allowed actions, issue time, and expiry; validate that contract against current server state on every privileged action, and make revocation advance the epoch before presence is removed. This gives reconnects one unambiguous outcome: a client presenting an older epoch is denied and must obtain a fresh contract.
The contract is an authorization snapshot, not proof that a person is online. Presence is a derived, expiring view of accepted connections. Keeping those meanings separate is the central decision in this architecture record.
What invariants make realtime access revocation reliable in an online classroom?
The first invariant is temporal: once the authoritative epoch for (classroom_id, subject_id) moves from 17 to 18, no operation carrying epoch 17 may create a new effect. That includes a WebRTC signaling message, a request to publish media, a chat write, and a presence heartbeat. An already queued message is not grandfathered in merely because it entered a client buffer before the teacher clicked remove.
The second invariant is about ordering. Revocation must commit before the system broadcasts the resulting absence. If the presence event goes first, another node can accept a late heartbeat under the old grant and make the student appear to return. The transaction does not need to encompass every network send; it needs to durably advance the epoch and record an outbox event together, after which delivery may retry. Consumers deduplicate that event by event_id, while projectors reject any event whose epoch is lower than the latest one they have applied.
No socket is trusted.
The third invariant separates authentication, authorization, and presence. Authentication establishes the subject. The access contract states what that subject may do in one classroom at one epoch. Presence says that at least one recently accepted connection is associated with the subject. Conflating those layers produces the familiar race in which a valid login token silently becomes a permanent classroom pass, or a disconnected socket removes a student who still has a second active device.
For auditability, record a revocation decision with classroom_id, subject_id, old and new epochs, actor, reason, decision time, and a unique command ID. A retry with the same command ID must return the original decision rather than advance the epoch again. That is the exactly-once mindset applied at the boundary where it is achievable: effects are idempotent even though delivery is at least once. Retain these records according to the institution's legal and compliance policy; there is no universal retention period that can be inferred from the transport design.
The failure boundary deserves equal precision. A regional partition can delay a presence update, and an offline client can miss the revocation broadcast entirely. Neither is allowed to restore authority. The reconnect handshake reads or verifies the current epoch before it admits the connection, so the broadcast is merely a latency optimization. Correctness rests on authoritative state.
The contract and the comparison
A compact contract is easier to audit than a bag of loosely related claims. In this design, grant_id identifies one issuance, auth_epoch fences old grants, actions is an allow-list, and expires_at bounds how long an unrefreshed snapshot can survive. connection_id does not belong in the durable grant because one subject may reconnect or use multiple devices; it belongs in the ephemeral presence lease.
Consider a student whose laptop sleeps at epoch 42 after joining from both a browser tab and a tablet. While the laptop is asleep, an instructor submits revocation command rvk-8841; the server records that command once, advances the subject's epoch to 43, and lets both presence leases expire or be removed by the resulting event. The sleeping browser receives none of those messages. When it wakes, the transport may reconnect successfully, but its contract still says 42, so the admission service denies the join, does not renew presence, and returns a domain reason such as stale_authorization. A duplicated delivery of rvk-8841 must return the recorded transition rather than advance the epoch to 44, because otherwise retries would change the meaning of later audit records. If an administrator subsequently restores access, the service issues a new contract at the then-current epoch; it never lowers the counter or resurrects the old grant. The tablet follows the same rule despite having a different connection ID. This is why the data model keys authority by classroom and subject, while presence remains per connection: delayed events can make the view temporarily incomplete, but they cannot make an older credential current again.
Expiry is only a backstop.
| Option | Revocation behavior | Presence consequence | Operational trade-off |
|---|---|---|---|
| Short-lived access contract only | Revocation waits for expiry | A removed student may remain present until the deadline | Few authoritative reads, but the maximum stale-access window equals the remaining lifetime |
| Epoch check on privileged actions | Old contracts fail as soon as the new epoch is visible | Presence can be removed after the epoch commit | Strong boundary, with a read or coherent cache lookup on the critical path |
| Per-connection deny list | Listed connections fail | A new connection identifier can escape the old entry | Simple for emergency disconnects, but identity-level revocation requires extra coordination |
| Central session introspection | Every accepted action consults one authority | Presence follows one decision source | Straightforward semantics, with added latency and an availability dependency |
The selected option is the epoch check, backed by a short cache lifetime or invalidation channel whose failure mode is denial once freshness cannot be established. The catch is that it is not suitable when the action path cannot tolerate an authorization lookup and the business can explicitly accept access until contract expiry. In that narrower case, stick with very short-lived contracts and document the stale-access bound as a product decision, not as an implementation detail.
I'm not sure a single cache strategy is correct for every deployment. The choice depends on the maximum permitted revocation delay, regional topology, and what the classroom must do during loss of the authority store; a fault-injection test with measured propagation time resolves that question better than architectural preference.
Critical path in Go
The following core is deliberately transport-agnostic. It shows the contract check that should run before a privileged effect and the presence lease is renewed only after admission succeeds. Production code still needs authenticated contract parsing, durable epoch storage, bounded timeouts, and an outbox for the resulting audit and presence events.
package access
import (
"context"
"errors"
"time"
)
var (
ErrExpired = errors.New("access contract expired")
ErrStaleAuthorization = errors.New("stale authorization epoch")
ErrActionDenied = errors.New("action is not allowed")
)
type Contract struct {
GrantID string
ClassroomID string
SubjectID string
AuthEpoch uint64
Actions map[string]bool
ExpiresAt time.Time
}
type EpochStore interface {
CurrentEpoch(ctx context.Context, classroomID, subjectID string) (uint64, error)
}
type PresenceStore interface {
Renew(ctx context.Context, classroomID, subjectID, connectionID string, until time.Time) error
}
type Gate struct {
Epochs EpochStore
Presence PresenceStore
Now func() time.Time
LeaseTTL time.Duration
}
func (g Gate) Admit(
ctx context.Context,
contract Contract,
action string,
connectionID string,
) error {
now := g.Now()
if !now.Before(contract.ExpiresAt) {
return ErrExpired
}
if !contract.Actions[action] {
return ErrActionDenied
}
current, err := g.Epochs.CurrentEpoch(
ctx,
contract.ClassroomID,
contract.SubjectID,
)
if err != nil {
return err
}
if contract.AuthEpoch != current {
return ErrStaleAuthorization
}
return g.Presence.Renew(
ctx,
contract.ClassroomID,
contract.SubjectID,
connectionID,
now.Add(g.LeaseTTL),
)
}
Reject first.
Notice what the function does not do: it does not infer access from an open peer connection, and it does not renew presence before authorization succeeds. A caller should map the three domain errors to a stable client-facing result, while dependency errors follow the classroom's documented fail-closed policy. The client may retry a read-only handshake with the same grant_id; it must not retry a rejected privileged write under a new identity or silently translate rejection into offline success.
Test the race, not merely the happy path. A deterministic test should pause an Admit call after it reads epoch 42, commit a revocation to 43, and then verify that the action cannot commit without a final epoch guard in the same consistency boundary. If the store cannot provide that conditional write, route privileged effects through a serialized classroom-subject command stream or attach the epoch to the write and reject it atomically. Also test two simultaneous connections, duplicated revocation commands, delayed outbox delivery, clock skew around expiry, process restart, and reconnect after the client misses every notification.
Three telemetry signals matter more than a generic connected-client count: the distribution of revocation-to-denial latency, the number of stale-epoch admissions rejected, and the difference between authorized subjects and active presence leases. Logs should carry command_id, grant_id, epoch, classroom, subject, connection, action, and decision without storing raw media or chat content. Alerts can then distinguish a noisy reconnect storm from a real authorization-propagation breach.
Rejected option and its valid use case
The rejected design makes a WebRTC peer connection or data channel the authorization boundary: close it on revocation, then assume any later connection is legitimate because signaling admitted it. The W3C WebRTC Recommendation defines the browser-facing peer-connection model, while signaling remains an application concern; an application-level classroom grant therefore still needs its own semantics. Transport state can tell us that a path exists. It cannot, by itself, answer whether a subject remains entitled to use that path.
This option is still valid for rooms where access never changes during a session, all content is public to authenticated users, and a best-effort disconnect is the stated policy. It has lower state-management complexity and can be entirely reasonable for a casual event. It is not suitable for moderated classrooms that require removal to survive sleep, network switching, tab restoration, or a second device. There, treating connection closure as revocation leaves the most important state transition outside the auditable data contract.
The decision also carries cost and team consequences. Epoch checks add datastore or cache traffic, outbox processing, reconciliation jobs, and on-call dashboards; they require backend, client, and security teams to agree on reason codes and retry behavior. Those costs buy a bounded, testable authorization rule. If the organization cannot operate the added state safely, it should narrow the promised revocation semantics rather than advertise an immediacy guarantee that the architecture cannot enforce.
The final acceptance criterion is concise: after a revocation commits, no older epoch can create an effect, renew presence, or become valid through reconnect. Everything else β notifications, socket closure, UI updates β follows from that invariant.
References
- W3C, βWebRTC: Real-Time Communication in Browsersβ: https://www.w3.org/TR/webrtc/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.