# From platform identity to player data: reviewing Steam and EOS trust boundaries

Leandro Pareja · Application security research · Published October 19, 2026

## The research in brief

I discovered and reported an information-exposure issue in a multiplayer game’s online services. My C# research client traced how Steam authentication led to access to session and player information. After my report to Studio Wildcard, checks were added to validate the requesting client ID, and the affected requests now return **HTTP 403 Forbidden**.

- **My role:** C# development, authentication-flow analysis, API testing, and responsible disclosure.
- **Why it mattered:** Proving a player’s identity should not automatically give a client access to player information beyond its intended permissions.
- **Outcome:** The affected access path was patched. Requests from my research client are now rejected with HTTP 403 Forbidden.

## Disclosure & patch status

After my report, checks were added to validate which client ID was requesting the information. The affected requests from my research client now return **HTTP 403 Forbidden** instead of the previously accessible information. The exchange flow and C# example below document my original research, before the patch.

## Technical context

My client connected Steam authentication to Epic Online Services (EOS). It obtained a platform-issued session ticket—proof used to verify a Steam account—then exchanged it for an EOS access token and product user ID. It used that authenticated context to look up sessions and request player details. I also added polling, persistence, and Discord notifications to compare observations over time.

## The intended security model

The workflow crosses three distinct boundaries:

1. **Steam identity:** a platform-issued ticket provides material that a relying service must validate through the appropriate Steam integration.
2. **EOS identity and permissions:** the exchange returns credentials for EOS operations in a configured application environment. An identifier alone is not proof of identity, and a valid token is not universal permission.
3. **Application data access:** each read must satisfy the product’s policy for the requested session, target player, and response fields. Public session discovery and privileged account enrichment are different operations.

Steam distinguishes game-session ticket verification from its Web API ticket flow. Its documentation describes backend verification and validation callbacks; a local handle or initial validity check does not substitute for the appropriate verification result. The Web API flow uses `GetAuthTicketForWebApi`, waits for its response callback, and validates the ticket on a secure backend. These ticket types should not be treated as interchangeable. [Steamworks: authentication and ownership](https://partner.steamgames.com/doc/features/auth).

EOS client policies define which services and actions a client may access. Epic explicitly warns against distributing credentials associated with a trusted-server policy in a public game build. The effective policy—not a recognizable SDK header—is the meaningful permission boundary. [Epic: clients and client policies](https://dev.epicgames.com/docs/epic-online-services/eos-fundamentals/client-and-client-policy/client-policy-guide).

## How I connected the exchange to player data

| Stage | Implementation evidence | What it establishes |
| --- | --- | --- |
| Platform login | `SteamClientExample` uses SteamKit2 callbacks to connect and log on. | The implemented path starts with Steam account authentication; it is not an anonymous client. |
| Ticket acquisition | `GetFreshTicketCtx` requests an application ticket and returns its hex representation with a disposable context. | The client obtains ticket bytes and retains their context during the subsequent operations. |
| Identity exchange | `EpicGamesAuth.GetEosAuthTokenAsync` sends a form-encoded external-auth request, identifying a Steam session ticket, to an EOS token endpoint. | The code attempts an identity exchange using configured client authentication and deployment context. |
| Token consumption | `Program.InitialPlayerListUpdate` and `FetchAndPrintMatchmakingData` extract `access_token` and `product_user_id` from the exchange response. | The active callers use the returned product user ID rather than demonstrating substitution of an arbitrary player ID. |
| Session query | `GetMatchmakingDataAsync` sends a bearer-authenticated request to a deployment-scoped player-session filter. | The client attempts a session lookup associated with that principal. |
| Player expansion | `ParsePlayers` collects `publicPlayers` identifiers; `GetUserDetailsAsync` submits them to a product-user search operation. | Session membership information becomes input to a second data lookup. |
| Enrichment and output | `ParsePlayerDetails` reads account identifiers, display names, provider identifiers, and last-login fields. `Program` contains persistence and notification logic. | The intended downstream use correlates identity and session observations over time.  |

The routes my client uses include an OAuth token exchange, a matchmaking player filter, and a product-user search. Exact deployment identifiers, credentials, account values, and reusable request bodies are intentionally omitted. These route descriptions document the implementation; they are not a claim that every observed route is a supported public API contract.

The code requests a hexadecimal ticket representation. Hex encoding changes representation; it neither creates an identity nor makes a ticket authentic. Likewise, the copied SDK user-agent, correlation header, and version headers describe request metadata. Their presence does not establish that the service uses those headers as authentication factors.

## Root cause analysis: identity is not permission

### 1. The candidate boundary failure is authorization after authentication

The client first proves a platform identity, then asks for session and player information. The central question is whether the resulting principal is permitted to obtain each returned object and attribute.

If an ordinary player credential can retrieve account-level details beyond the product’s intended visibility rules, the failure belongs at the authorization or response-disclosure boundary. Possible causes include an overly broad client policy, missing object-level checks, or excessive response fields. My client alone cannot identify which of those controls is actually responsible: it contains neither the effective policy nor the responding service’s validation logic.

The post-patch client-ID checks and HTTP 403 responses are consistent with enforcing authorization at this boundary. They do not reveal the exact internal defect or establish whether the change was made in a client policy, a service handler, or another control. A successful authenticated read can still be legitimate when the requesting client and principal have the required permissions.

### 2. Public session discovery does not justify every enrichment operation

`ParsePlayers` explicitly reads `publicPlayers`. That name alone does not prove a privacy violation. Epic documents public-advertised sessions that other users can discover through registered-player searches. Discoverability may be expected behavior. [Epic: sessions introduction](https://dev.epicgames.com/docs/epic-online-services/multiplayer/lobbies-and-sessions/sessions-interface/sessions-intro).

The more consequential boundary is the transition from discovery to account correlation: which linked identifiers and timestamps may the requesting principal obtain, and for what purpose? Those decisions need field-level and object-level policy. My source shows the lookup chain, not the policy that governed an actual response.

### 3. Polling can amplify a permitted or excessive disclosure

The monitoring loop waits ten seconds after each iteration. It compares player-ID sets between snapshots, prepares join/leave notifications, and updates session records. `PlayerInfo` models persistent account and session-history fields.

Repeated collection can turn a single lookup into a longitudinal activity record. This is a potential impact multiplier if the reads are overbroad; it is not proof of the collection’s deployed scale. Snapshot differences also measure observed presence between polls, not exact authoritative join or leave times. Missing or incomplete data must not be interpreted as a confirmed departure.

## Implementation findings in the research client

While revisiting my implementation, I also identified improvements I would make to the research client itself. These are separate from the service-side authorization question.

**Credential handling.** My original source embeds platform credentials, a bot credential, and an EOS client-authentication value. Treat active account credentials as exposed and rotate or revoke them. Classify the EOS value by its actual client policy: a distributed client credential is not a confidential server secret and must not grant trusted-server authority. Credential literals, tokens, player identifiers, and destination IDs are excluded from this article.

**Readiness and ticket lifecycle.** `Main` starts the Steam callback loop without awaiting an explicit logged-on readiness signal before starting the rest of the application. That creates a potential readiness race. The active polling path holds the ticket context in a `using` block, which scopes normal disposal, but forced process termination cannot guarantee that cleanup completes. `SteamAuth.cs` is an alternative wrapper whose initialization is commented out in `Program`; it cancels the current handle on entry and logs its callback result rather than gating ticket consumption on it. These are lifecycle concerns, not evidence of backend acceptance of invalid tickets. The wrapper’s 55-minute threshold is a local heuristic, not an established platform ticket lifetime.

**Response and failure handling.** The token and session helpers return `null` for several failures. The main loop can retry session reads and terminate the process on token failure. Failed reads stop that cycle; the code does not show a fail-open authentication fallback. However, authentication failure, permission denial, transport errors, and malformed responses need separate handling so outages do not become misleading security conclusions.

**Data association.** `ParsePlayers` aggregates identifiers across returned sessions while repeatedly overwriting a single `sessionData` dictionary. Later code uses the first session name in places. With multiple sessions, metadata and player sets can therefore be associated inconsistently. Use a session-keyed model; do not infer a player’s exact server solely from the current aggregate representation.

**HTTP and logging hygiene.** The helper creates an `HttpClient` for each request and builds form data through string interpolation. Prefer a managed client lifetime, cancellation and bounded timeouts, and a form encoder. Commented diagnostic statements would expose tokens and raw bodies if enabled. Replace them with redacted error categories, correlation IDs, and bounded retention for player information.

## My exchange flow: a conceptual C# proof of concept

This diagram follows the sequence I implemented: a Steam session ticket, an EOS identity exchange, a session lookup, and a player-detail lookup. For publication, I have replaced the network operations with placeholders and synthetic values. The C# examples retain the reviewed class and key method names, with simplified signatures, model properties, and JSON shapes. They are edited illustrations, not verbatim original files. HTTP and platform integrations are omitted.

```text
TEST PLATFORM IDENTITY
        ↓
PLATFORM TICKET → SERVICE IDENTITY EXCHANGE
        ↓
AUTHENTICATED TEST PRINCIPAL
        ↓
SESSION METADATA LOOKUP → SYNTHETIC PLAYER REFERENCES
        ↓
PLAYER-DETAIL LOOKUP
        ↓
COMPARE RETURNED OBJECTS AND FIELDS WITH EXPECTED POLICY
        ├─ permitted and minimal → expected behavior
        └─ prohibited or excessive → record candidate exposure
```

```csharp
using System;
using System.Text.Json;
using System.Threading.Tasks;

// Edited public walkthrough. Names follow the reviewed research client.
// Live authentication, collection, persistence, and notifications are omitted.
internal static class Program
{
    public static async Task InitialPlayerListUpdate()
    {
        using var steamClient = new SteamClientExample();
        var (ctx, hex) = steamClient.GetFreshTicketCtx();
        using (ctx)
        {
            using var authResponse = await EpicGamesAuth.GetEosAuthTokenAsync(hex);
            if (authResponse is null) return;

            string accessToken = authResponse.RootElement
                .GetProperty("access_token").GetString()!;
            string productUserId = authResponse.RootElement
                .GetProperty("product_user_id").GetString()!;

            using var matchmakingData = await MatchmakingService
                .GetMatchmakingDataAsync(productUserId, accessToken);
            if (matchmakingData is null) return;

            var (players, sessionNames, sessionData) =
                MatchmakingService.ParsePlayers(matchmakingData);

            using var userDetails = await MatchmakingService
                .GetUserDetailsAsync(players, accessToken);
            if (userDetails is null) return;

            var playerDetails = MatchmakingService.ParsePlayerDetails(userDetails);
            Console.WriteLine($"Parsed {playerDetails.Count} synthetic players.");
        }
    }
}
```

For this type of test, compare the returned objects and fields with the documented permissions for the requesting identity and client. A successful login alone does not establish that the subsequent data access is authorized.

## Defensive remediation

### Patch 1: establish identity on the authoritative side

Select the provider-supported verification flow for the credential being presented. For Steam session authentication, process the applicable backend verification result and lifecycle callbacks. For a Steam Web API backend, use its corresponding Web API ticket flow. An EOS access token used to call a service should not be assumed to be the same credential type as an EOS Connect ID token presented to a game server.

Where EOS Connect ID tokens are appropriate, use the supported verification mechanism, such as `EOS_Connect_VerifyIdToken`, and bind the verified subject and application context to the connection. Epic documents Connect ID-token verification for verifying another EOS user’s identity. Do not infer the token format or verification method for the reviewed OAuth response merely from its field name. [Epic: EOS SDK release notes, Connect ID-token APIs](https://dev.epicgames.com/docs/epic-online-services/release-notes).

### Patch 2: enforce authorization per operation and object

For each read, evaluate the verified subject, permitted action, deployment context, target object, and the product’s intended visibility rules. Derive the caller from verified credentials rather than a request-supplied identity. Public discovery, authorized co-player visibility, and administrative player lookup should have explicitly different permissions where the product requires them.

Keep privileged EOS clients on trusted infrastructure and restrict their policies to the required actions. Use a user-required player policy where appropriate. Review any distributed client credential as recoverable, and reduce its authority accordingly. Rotating a broad client credential without fixing its permissions does not repair the trust boundary.

### Patch 3: minimize the response

Return only fields the caller needs. Avoid exposing linked external account IDs, login timestamps, or historical activity simply because a player appears in a public session. Apply the approved field projection after authorization and before serialization. If a lookup needs several targets, validate every target and avoid partial-response leakage.

### Patch 4: treat validation failure as a denied sensitive operation

An absent or inconclusive verification result must not be treated as a verified identity for protected reads. Keep unverified connections in a limited state where appropriate. Deny the sensitive operation on invalid or expired credentials, wrong application context, or insufficient permissions; do not silently fall back to an anonymous read. Return structured failures without raw credentials or unnecessary account details.

```text
CONCEPTUAL SERVER POLICY — not an executable patch

subject := VERIFY_WITH_APPROPRIATE_PROVIDER_MECHANISM
if verification is invalid or inconclusive:
    DENY_PROTECTED_READ

if subject.context is not EXPECTED_APPLICATION_CONTEXT:
    DENY_PROTECTED_READ

for each requested object:
    if POLICY does not permit subject to read that object:
        DENY_WITHOUT_DISCLOSING_OBJECT_DETAILS

return only POLICY_APPROVED_FIELDS
```

This identifies where a patch belongs; it is not a drop-in patch for an unseen server implementation.

### Patch 5: contain collection and test the boundary

Rate-limit by principal, client, and operation; bound batch sizes; record unusual target expansion and repetitive polling. Protect downstream exports with channel access controls, retention limits, and deletion rules. Use session-keyed observations and distinguish stale, partial, and failed snapshots from confirmed state changes.

| Authorized regression case | Required result |
| --- | --- |
| Missing, malformed, expired, or cancelled credential, where applicable to that credential type | No protected player data; cancellation and expiry enforced according to provider semantics. |
| Valid identity with wrong deployment or application context | Denial before reading protected objects. |
| Ordinary identity requesting an unrelated private player or session | Denial under the documented visibility policy. |
| Valid permitted public discovery | Only the intended discoverable session fields. |
| Mixed authorized and unauthorized batch targets | Every target checked; no unauthorized fields leaked. |
| Verification timeout or provider outage | Sensitive reads remain unavailable; failure is distinguishable from an empty result. |
| Token revocation or session termination | Access ceases according to the provider’s lifecycle and documented application policy. |
| Partial monitoring snapshot | Mark uncertain; do not write fabricated join or leave times. |

## Conclusion

My main takeaway is that platform identity, EOS permissions, and player-data visibility must be assessed as separate boundaries. My client documents an authenticated path from one account to session references and potential account enrichment. I reported the issue, and the affected access path has since been patched: the service now checks the requesting client ID, and my research client receives HTTP 403 Forbidden for the affected requests.

For developers, the corrective direction is concrete: verify identity with the correct provider mechanism, enforce the intended object and field permissions on the server, keep privileged credentials off player-controlled systems, and reject sensitive reads when validation is unresolved.

## Research scope and evidence

This report describes my authenticated information-exposure research, rather than a forged-ticket login or account takeover. I reported the issue to Studio Wildcard; the patch outcome reflects my disclosure and follow-up observations. I am not publishing an exact patch date, runtime captures, vendor correspondence, the effective EOS policy, or server implementation.

My caller uses the product user ID returned by the token exchange. This report does not establish acceptance of invalid, expired, cancelled, or wrong-application tickets, arbitrary user-ID substitution, cross-deployment access, or a game-server admission bypass. The public C# example is conceptual and does not call live services.

## Source map

- `SteamClientExample.cs`, `GetFreshTicketCtx` (line 90): active ticket acquisition wrapper.
- `Program.cs`, `InitialPlayerListUpdate` (line 217), `FetchAndPrintMatchmakingData` (line 426), and `PeriodicPlayerCheck` (line 965): orchestration and snapshot monitoring.
- `EpicGamesAuth.cs`, `GetEosAuthTokenAsync` (line 9): configured external-auth exchange.
- `MatchmakingService.cs`, `GetMatchmakingDataAsync` (line 24), `ParsePlayers` (line 109), `GetUserDetailsAsync` (line 156), and `ParsePlayerDetails` (line 190): lookup and enrichment path.
- `SteamAuth.cs`, `GetSessionTicket` (line 45): alternative wrapper, not the active initialization path in my entry point.
- `HttpClientHelper.cs`, POST and GET helpers (lines 11 and 36): HTTP request construction.
- `PlayerInfo.cs` (line 6): account and session-history data model.


Line numbers refer to my original research files. The conceptual C# example above omits live service calls and credentials.

## Helper-file walkthrough

These edited helper examples correspond to the website’s file explorer.

### EpicGamesAuth.cs

Receives the Steam ticket and reads the exchange response. The public helper omits the live endpoint, client authentication, and deployment configuration.

```csharp
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;

public static class EpicGamesAuth
{
    public static async Task<JsonDocument?> GetEosAuthTokenAsync(string steamTicket)
    {
        // Same exchange concept as my research client; values are placeholders.
        var formData = new Dictionary<string, string>
        {
            ["grant_type"] = "external_auth",
            ["external_auth_type"] = "steam_session_ticket",
            ["external_auth_token"] = steamTicket
        };

        return await HttpClientHelper.PostAsync("IDENTITY_EXCHANGE", formData);
    }
}
```

### MatchmakingService.cs

Connects session discovery to player-detail parsing. ParsePlayers and ParsePlayerDetails now appear in the code, matching the names discussed in the report. JSON shapes are simplified for this illustration.

```csharp
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;

public static class MatchmakingService
{
    public static Task<JsonDocument?> GetMatchmakingDataAsync(
        string localUserId, string accessToken) =>
        HttpClientHelper.GetAsync("SESSION_LOOKUP", localUserId, accessToken);

    public static (HashSet<string> players, List<string> sessionNames,
        Dictionary<string, string> sessionData) ParsePlayers(JsonDocument data)
    {
        var players = new HashSet<string>();
        var sessionNames = new List<string>();
        var sessionData = new Dictionary<string, string>();

        // Simplified synthetic session schema, not an API response contract.
        foreach (var session in data.RootElement.GetProperty("sessions").EnumerateArray())
        {
            sessionNames.Add(session.GetProperty("name").GetString()!);
            foreach (var player in session.GetProperty("publicPlayers").EnumerateArray())
                players.Add(player.GetString()!);
        }

        // Kept to explain the original tuple; no aggregate metadata is assigned.
        // Production improvement: associate players and metadata by session ID.
        return (players, sessionNames, sessionData);
    }

    public static Task<JsonDocument?> GetUserDetailsAsync(
        HashSet<string> players, string accessToken) =>
        HttpClientHelper.PostAsync("PLAYER_DETAILS", new { productUserIds = players }, accessToken);

    public static List<PlayerInfo> ParsePlayerDetails(JsonDocument data)
    {
        var playerDetails = new List<PlayerInfo>();
        foreach (var player in data.RootElement.GetProperty("players").EnumerateArray())
        {
            playerDetails.Add(new PlayerInfo
            {
                ProductUserId = player.GetProperty("productUserId").GetString()!,
                DisplayName = player.GetProperty("displayName").GetString()!
            });
        }
        return playerDetails;
    }
}
```

### HttpClientHelper.cs

Shows where the HTTP boundary belongs. Calls deliberately stop here: no live URLs, headers, credentials, or requests are included. Unlike the original, these edited signatures accept structured data.

```csharp
using System;
using System.Text.Json;
using System.Threading.Tasks;

public static class HttpClientHelper
{
    public static Task<JsonDocument?> PostAsync(
        string operation, object data, string? accessToken = null) =>
        throw new NotSupportedException("Live HTTP POST omitted from public walkthrough.");

    public static Task<JsonDocument?> GetAsync(
        string operation, string localUserId, string accessToken) =>
        throw new NotSupportedException("Live HTTP GET omitted from public walkthrough.");

    // Implementation guidance:
    // - Reuse a managed HttpClient and set timeouts / cancellation.
    // - Encode form fields with FormUrlEncodedContent.
    // - Serialize JSON bodies instead of interpolating strings.
    // - Distinguish 403 Forbidden from transport or parsing failures.
    // - Never log tickets, access tokens, or client credentials.
}
```

### SteamClientExample.cs

The active ticket wrapper in the reviewed client used SteamKit2. This illustration preserves GetFreshTicketCtx and its context/ticket return shape, but omits platform login and ticket acquisition.

```csharp
using System;

public sealed class SteamClientExample : IDisposable
{
    public (IDisposable ctx, string hex) GetFreshTicketCtx()
    {
        // Original integration: await platform login, acquire the ticket,
        // return its disposable context and hexadecimal representation.
        throw new NotSupportedException("SteamKit2 integration omitted.");
    }

    public void Dispose()
    {
        // Original integration: disconnect and release platform resources.
    }
}
```

### PlayerInfo.cs

A minimal illustrative model for the parsed player records. The original model also tracked account/provider details and session history. Property names here are edited for readability.

```csharp
// Synthetic records only. Reduced model for this public walkthrough.
public sealed class PlayerInfo
{
    public string ProductUserId { get; init; } = string.Empty;
    public string DisplayName { get; init; } = string.Empty;
}
```

### SteamAuth.cs

Alternative Steamworks ticket wrapper. It was not the active initialization path in Program.cs; keeping it separate avoids suggesting that both wrappers ran in the same exchange.

```csharp
using System;

public static class SteamAuth
{
    public static string GetSessionTicket()
    {
        // Alternative wrapper from the research project.
        // Public example omits ticket cancellation, creation, and callbacks.
        throw new NotSupportedException("Alternative Steamworks integration omitted.");
    }
}
```

