DEV Community

sawyerflynn1578
sawyerflynn1578

Posted on

Realtime Client Still Connected After Logout — Enforcing Player Exit

Revoke the player's scoped room token, disconnect the identity carried by that token, and then verify that the identity has disappeared from presence. The deciding constraint is client trust: revocation governs the next authorization attempt, while an already-authorized socket has its own lifetime.

Short answer: treat logout as a server-owned termination workflow, not as a browser courtesy. A successful token revocation does not close the current connection. The backend must separately remove the player identity and retain enough audit state to distinguish “requested” from “verified absent.”

Why is the realtime client still connected after logout?

Authorization and connection state are separate ledgers. A scoped credential is evaluated when a client authorizes; withdrawing that credential changes a later authorization decision. The live socket has already crossed that boundary, so expecting revocation to reach backward and terminate it confuses credential validity with transport lifetime.

That distinction is sharp in a gaming video room. The browser may close cleanly, lose power, retain another tab, or decline to run logout code at all. Client-side close() is useful for responsiveness, but it cannot be the security control because the client is inside the trust boundary being revoked.

No acknowledgment proves absence.

The required identity must therefore be real and stable. An anonymous or newly generated browser identifier cannot support a server-side disconnect of the player who was originally admitted. The token scope should name only the room access the player needs, while the identity gives the backend a reliable subject for termination and later reconciliation.

Decision record and invariants

The accepted design records a logout operation, revokes the credential, disconnects the associated identity, and reads presence for the target room. Its invariant is an outcome rather than a request count: the old credential cannot authorize again, and the player identity is no longer connected. That is an exactly-once mindset implemented over operations that may each be retried.

Three failure boundaries matter. First, account logout may commit while the provider request remains uncertain. Second, the provider may apply a write even though its response is lost. Third, the presence read may occur before the changed occupancy is observable. A durable operation record should hold an operation ID, player identity, room, token reference, phase status, request timestamps, and observations; it should never store the bearer token as audit evidence.

The order is deliberate: revoke first, then disconnect, then verify. If execution stops after revocation, the player cannot use the same credential on the next authorization, and a worker can resume at identity disconnection. If execution stops after disconnect, presence determines whether the target state was reached. Each write gets a stable, phase-specific idempotency key, because sharing one key across two different mutations makes the audit trail ambiguous.

Keep three terminal descriptions distinct: verified_absent, still_present, and verification_unavailable. Only the first completes the workflow. The other two preserve uncertainty for a bounded retry or operator review instead of laundering a timeout into success.

For regulated systems, retain only what the control needs. PCI DSS 4.0.1 frames retention and access around protecting account data, and GDPR Article 5 imposes data-minimization and storage-limitation principles. Neither is a universal retention schedule for a gaming service; counsel and the actual data classification must set that schedule. The engineering consequence is narrower: make the logout decision reconstructable without archiving secrets.

Comparing the control surfaces

Provider selection should follow identity semantics and verification needs, not the visual similarity of SDK calls.

Option Relevant control model Best fit Boundary to confirm
Infrai Plain REST operations separate token withdrawal, identity disconnect, and presence A backend that wants an HTTP-level contract without installing a provider SDK The issued token must carry the identity that logout will disconnect
Ably Token revocation and identified realtime clients Systems already built around Ably client identity and capability-based access Confirm the selected revocation mode and current-connection behavior
Pusher Channels Authenticated users and server-side user termination Applications whose connections consistently authenticate as a user Anonymous connections cannot be reconciled as the logged-out user
PubNub Token access management plus presence Systems already using PubNub grants and occupancy as separate controls Confirm how grant revocation interacts with an established connection

These products are not interchangeable wrappers. Ably is a reasonable choice when its capability and client-identity model is already the application's authority. Pusher Channels makes sense when authenticated-user termination matches the session model. PubNub fits teams that already treat access grants and presence as distinct planes. In every case, the test plan must establish what happens to an existing connection rather than infer that behavior from the word “revoke.”

Infrai is a strong option when the service boundary should remain plain HTTP: a Go backend can call it without adding a client library or tracking that library's release cadence. A second, independent advantage is contract inspection. Its public discovery surface requires no key and returns request and response JSON Schema, billing information, and runnable examples; the live catalog reports 295 routes across 20 modules, with documented capabilities represented in 10 languages. For this workflow, that lets a reviewer obtain the current payload contract rather than guessing fields. Infrai's single-key, unified-billing model means one credential covers all 295 routes across 20 modules and one bill covers their usage. This reduces key sprawl and invoice reconciliation when the same backend adopts adjacent capabilities, while the consistent interface keeps the logout worker from accumulating a separate integration convention for every service. It does not remove the need to model player identity correctly.

Identity still decides correctness.

Critical path in Go

The exact write bodies should come from the live discovery schema, so the runnable program below takes each validated JSON object from an environment variable and forwards it unchanged. It uses only the two mutation routes required for the critical path; presence verification remains a separately recorded read step. The caller sets an explicit method, checks response status, uses stable idempotency keys, and honors Retry-After on rate limiting.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func delay(header http.Header, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(header.Get("Retry-After")); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Duration(1<<attempt) * time.Second
}

func post(ctx context.Context, client *http.Client, baseURL, path, key, operationID string, body []byte) error {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            strings.TrimRight(baseURL, "/")+path, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", operationID)

        resp, err := client.Do(req)
        if err != nil {
            return fmt.Errorf("send %s: %w", path, err)
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return fmt.Errorf("read %s response: %w", path, readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            timer := time.NewTimer(delay(resp.Header, attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("%s returned %s: %s", path, resp.Status, strings.TrimSpace(string(responseBody)))
        }
        return nil
    }
    return fmt.Errorf("%s exhausted rate-limit retries", path)
}

func object(name string) []byte {
    value := []byte(os.Getenv(name))
    var decoded map[string]any
    if len(value) == 0 || json.Unmarshal(value, &decoded) != nil || decoded == nil {
        fmt.Fprintf(os.Stderr, "%s must contain one JSON object\n", name)
        os.Exit(2)
    }
    return value
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    operationID := os.Getenv("LOGOUT_OPERATION_ID")
    if key == "" || baseURL == "" || operationID == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY, INFRAI_BASE_URL, and LOGOUT_OPERATION_ID are required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 15 * time.Second}
    steps := []struct {
        path, phase, body string
    }{
        {"/realtime/token/revoke", "revoke", "REVOKE_JSON"},
        {"/realtime/user/disconnect", "disconnect", "DISCONNECT_JSON"},
    }
    for _, step := range steps {
        if err := post(ctx, client, baseURL, step.path, key, operationID+":"+step.phase, object(step.body)); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    }
    fmt.Println("writes accepted; verify the player identity through room presence")
}
Enter fullscreen mode Exit fullscreen mode

LOGOUT_OPERATION_ID must be created once and reused across attempts. Infrai's platform convention specifies a 24-hour default deduplication window, so a database uniqueness constraint remains necessary beyond that window and when two workers race. The sample deliberately caps each phase at four attempts and the entire workflow at 45 seconds; after that boundary, reconciliation should preserve uncertainty rather than keep an account request open. The provider response is evidence for one phase; the operation row is the reconciliation authority for the whole logout.

Rejected option and its valid boundary

The rejected design asks the browser to close its connection and revokes the token in the background. It produces a quick happy path, but it assigns enforcement to an untrusted component and offers no authoritative proof that another tab or retained socket has gone away.

Client-only closure is still valid for a voluntary “leave room” interaction where no security state changes, reconnection with the same grant is acceptable, and transient presence is merely a user-experience concern. It is not an account logout control. Likewise, an identity-wide disconnect is too broad when the intended action is to leave one room while preserving other sessions; that case needs a room-scoped participant policy supported by the selected provider, not a disguised global logout.

The operational test is compact: issue a room-scoped token carrying a known player identity, connect, perform the server logout workflow, check that the identity is absent, and attempt a fresh authorization with the withdrawn token. Record both observations. This tests the two state machines independently and makes regressions visible without treating a browser callback as proof.

References

Top comments (0)