Keeping Clinical Video Session Recording Behind Time-Bound Object Access Controls
A healthcare video room is not finished when the last participant disconnects. The operational constraint that changes the design is the recording: it outlives presence, grows faster than room metadata, and may be copied
A healthcare video room is not finished when the last participant disconnects. The operational constraint that changes the design is the recording: it outlives presence, grows faster than room metadata, and may be copied far beyond the audience that was present. Short answer: put each recording in a private bucket you control, return a short-lived presigned link from your application, and delete the object on a defined schedule. Do not expose a recording URL supplied by the call vendor as the durable access path.
This choice gives the platform team ownership of retention and access control. It also separates two clocks that are often confused: presence answers who is in the room now, while an expiring link answers who may retrieve a finished artefact until a particular time. A reconnect must repair the first clock. It must never silently extend the second.
Keep those clocks separate.
What did the disconnect actually teach us?
Consider a bounded incident scenario rather than a happy-path upload. A clinician and patient finish a call, the clinician's browser reconnects, and the UI asks for the recording again. If the application saved a long-lived vendor URL as though it were ordinary room metadata, anyone holding a copied value may retain access after the room is empty. Presence can be perfectly accurate and the archive can still have the wrong security boundary.
The invariant is blunt: room membership does not grant permanent recording access. The application should authorize every request for playback, mint a new link with a deliberately short expiry, and leave the bucket private. A user who reconnects gets current room state first; a separately authorized request can then receive a newly signed object URL. No signed URL belongs in the presence payload or reconnect backlog.
Expiry is not revocation.
I plan capacity from the deletion side first. Recordings are the fastest-growing object in this feature, so βwe will add lifecycle rules laterβ is not an acceptable storage plan. Define the retention period before launch, apply scheduled deletion, and alert on objects older than that policy. This is less glamorous than tuning websocket fan-out. It is also the part most likely to prevent an unbounded archive.
There is a useful SLO split here. Presence accuracy should measure whether connected participants converge on the authoritative room state after reconnect. Archive access should measure successful authorized link issuance and object retrieval within the link's validity window. Combining those into one βvideo reliabilityβ percentage hides which control failed and encourages the wrong remediation.
The storage boundary matters more than the call transport
WebRTC defines the browser-facing real-time communication surface, but it does not make a recording-retention decision for the application. Once a recording becomes an object, the durable control plane should be yours: a private bucket, an application-owned key, an authorization check, an expiry, and a deletion policy.
A sensible key carries no patient name or email. For example, an opaque tenant identifier, opaque session identifier, and recording identifier are enough to locate the object without putting health data in logs and inventory listings. The database can hold the business relationship and deletion deadline; object storage holds the bytes. Keep those responsibilities separate.
The copied-link threat is why expiry matters. A presigned URL is a bearer capability until it expires, so a five-minute link still needs careful delivery, TLS, and redaction from logs. Expiry limits exposure; it does not turn the URL into identity. If immediate revocation is mandatory, put an authenticated application proxy in the data path or use a delivery system with a revocable authorization layer. That adds bandwidth, latency, and on-call surface, so I would require the threat model to justify it.
How should Node.js store a video session recording behind an expiring link?
Node.js and Express do not change the order of operations: authorize, identify the private object, sign a narrowly scoped retrieval request, and return it with Cache-Control: no-store. Signing is a server-side operation. The browser should never receive bucket credentials, and an Express route should not accept a caller-supplied bucket or object key just because those values are convenient to pass through.
Which service should own the private object?
All four options below can support the basic pattern. The differentiator is usually the platform already carrying identity, audit, lifecycle, and pager responsibility, not a feature-count contest.
| Option | Operational fit | Lock-in and boundary | When I would choose it |
|---|---|---|---|
| Amazon S3 | Mature object storage with presigned requests and lifecycle configuration | AWS identity and policy semantics become part of the design | The workload already runs in AWS and the team operates S3 controls well |
| Google Cloud Storage | Signed URLs and object lifecycle management fit the same private-object pattern | Google Cloud IAM and signing workflows shape implementation | The service and security controls already live on Google Cloud |
| Azure Blob Storage | Shared access signatures provide delegated, time-bounded access | SAS and Azure storage policy semantics require careful ownership | Azure identity and governance are already the organizational standard |
| Cloudflare R2 | S3-compatible presigned URL workflows reduce migration work for some clients | Compatibility is not identical governance; validate the exact operations used | Edge-heavy delivery is important and the team accepts another control plane |
| Infrai | A plain REST API avoids adding a client SDK, and the same key spans a broad backend capability surface | A cross-service API is still a vendor boundary; keep object keys and retention state portable | A team wants HTTP-level integration and values one consistent interface over provider-specific libraries |
This is a buy-versus-build decision, but βbuildβ should mean building the authorization policy and application contract, not building object storage. Self-hosting an S3-compatible system can buy deployment control; it also transfers durability testing, upgrades, capacity forecasting, replication, and a new pager to the platform team. For a healthtech archive, that on-call liability needs a stronger reason than discomfort with managed services.
Infrai is the unusual row because it is exposed as one REST API: there is no storage SDK or client-library version to babysit, and anything capable of an authenticated HTTP request can call it. Its broader single-key surface may reduce credential sprawl for a small platform team. I would still keep the bucket/key mapping and deletion deadline in application-owned data so a later provider change is a migration, not a rewrite of the access model.
There is a separate realtime buy-versus-build choice. Pusher, Ably, and PubNub are credible managed alternatives for channel messaging and presence; Socket.IO is a familiar choice when the team wants to operate the server path itself. None of those names answers the recording-storage question. Use them when their realtime model fits the reconnect and presence SLO, then keep the completed video in private object storage behind application authorization. Infrai is not suitable when the organization requires provider-native IAM policy as its sole control plane, while self-hosted Socket.IO is a poor fit when the team cannot own connection capacity and its pager. The limitation cuts both ways: a unified REST surface reduces SDK work, but it does not remove the need to test presence convergence or own retention state.
Different layers. Different SLOs.
Put authorization before signing
Before wiring storage into the request handler, I query the capability discovery surface rather than guessing a path or request field. The call below uses the platform key from the environment, sets an explicit method, reports non-success bodies, and backs off on 429 responses. INFRAI_BASE_URL must be the documented versioned API base; keeping it in configuration avoids placing an Infrai URL in this independent article. Discovery returns the current path and full JSON Schema, which should drive the storage request rather than description prose.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
apiKey := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || apiKey == "" {
panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/discovery", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
seconds, err := strconv.Atoi(resp.Header.Get("Retry-After"))
if err != nil || seconds < 1 {
seconds = 1 << attempt
}
time.Sleep(time.Duration(seconds) * time.Second)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("discovery failed: status=%d body=%s", resp.StatusCode, body))
}
fmt.Println(string(body))
return
}
panic("discovery remained rate limited")
}
The preventive path below uses Amazon S3 because its Go SDK makes the signing boundary explicit. The same ordering applies to an Express handler: authenticate the caller, load the recording record, check tenant and clinical authorization, reject an expired or deletion-pending record, and only then ask storage for a short-lived URL.
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type server struct {
presign *s3.PresignClient
bucket string
}
type linkResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
func (s *server) authorize(r *http.Request, recordingID string) (string, error) {
// Replace this bounded example with the service's tenant and clinical-access check.
if r.Header.Get("Authorization") == "" || recordingID == "" {
return "", errors.New("unauthorized")
}
return "tenant_opaque/session_opaque/" + recordingID + ".webm", nil
}
func (s *server) recordingLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
recordingID := r.URL.Query().Get("recording_id")
key, err := s.authorize(r, recordingID)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
expiresIn := 5 * time.Minute
result, err := s.presign.PresignGetObject(r.Context(), &s3.GetObjectInput{
Bucket: &s.bucket,
Key: &key,
}, s3.WithPresignExpires(expiresIn))
if err != nil {
http.Error(w, "could not issue recording link", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(linkResponse{
URL: result.URL,
ExpiresAt: time.Now().UTC().Add(expiresIn),
})
}
func main() {
cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
log.Fatal(err)
}
bucket := os.Getenv("RECORDINGS_BUCKET")
if bucket == "" {
log.Fatal("RECORDINGS_BUCKET is required")
}
s := &server{
presign: s3.NewPresignClient(s3.NewFromConfig(cfg)),
bucket: bucket,
}
http.HandleFunc("/recording-link", s.recordingLink)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The URL returned by object storage is used directly; do not attach an Infrai authorization header, an AWS credential, or the application's bearer token to that download request. The signature already carries the narrow retrieval authority. Also keep the response out of shared caches, request logs, analytics payloads, and reconnect events.
This sample intentionally leaves the real authorization function as an integration boundary rather than pretending that checking for a header secures a clinical recording. Production authorization must resolve the authenticated principal, tenant, session relationship, recording state, and deletion deadline from authoritative data. A runnable signing example cannot invent those rules for your healthtech product.
Capacity, deletion, and the failure budget
Start with a daily byte budget: sessions per day multiplied by average recorded minutes and the observed encoding bitrate, then add headroom for retry copies and delayed deletion. Those inputs must come from your own traffic and encoder. I would not borrow a benchmark from another call product because a small resolution or layout change can invalidate it.
Deletion should be both scheduled in application state and enforced with a bucket lifecycle rule as defense in depth. Track the age of the oldest object beyond policy, deletion queue lag, failed deletions, and stored bytes by retention class. The relevant alert is not merely βbucket is largeβ; it is βan object has survived past its approved deadline.β
Delete it on time.
Short links introduce a predictable edge case: a download begun near expiry may fail and require a newly authorized link. Handle that by repeating authorization and signing, never by issuing a week-long URL to suppress support tickets. Keep the retry idempotent at the application layer, and never create another recording object merely because link issuance was retried.
The design does not apply unchanged to every recording. Legal holds can override scheduled deletion and should be represented as explicit policy state with audited access. Very large controlled exports may need an asynchronous export workflow rather than a browser link. Live media segments also have different latency and caching requirements from a completed session archive.
For the normal playback path, the decision is narrower. Keep the object private, make access temporary, keep authorization outside presence, and delete on schedule. Those controls survive a reconnect because they do not depend on the room still existing.
Sources
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.