⚠️ The latest release is unstable. Use preview builds instead
Skip to content

Steam Authentication Architecture

The steam-auth service runs in a separate container to isolate Steam credentials from the game server. It handles:

  • Steam authentication and session management
  • Game file downloads from Steam depots
  • Encrypted app ticket generation for GOG Galaxy cross-platform multiplayer

Architecture Diagram

┌─────────────────────────────────────┐
│   Game Server Container             │
│                                     │
│  ┌──────────────────────────────┐  │
│  │  AuthService.cs              │  │
│  │  (SteamAppTicketFetcherHttp) │  │
│  └──────────┬───────────────────┘  │
│             │ HTTP GET              │
│             │ /steam/app-ticket     │
└─────────────┼──────────────────────┘


┌─────────────────────────────────────┐
│   Steam Auth Container              │
│                                     │
│  ┌──────────────────────────────┐  │
│  │  HTTP Server (port 3001)     │  │
│  │  /health, /steam/app-ticket  │  │
│  └──────────┬───────────────────┘  │
│             │                       │
│  ┌──────────▼───────────────────┐  │
│  │  SteamKit2                   │  │
│  │  (login, 2FA, downloads)     │  │
│  └──────────────────────────────┘  │
│                                     │
│  Volumes:                           │
│  - steam-session (refresh tokens)  │
│  - game-data (shared with server)  │
└─────────────────────────────────────┘

Security Benefits

  1. Credential Isolation: Steam username and password never leave the steam-auth container
  2. Token Security: Refresh tokens remain private in a dedicated volume
  3. Minimal Exposure: Only ephemeral encrypted app tickets are exposed to the game server
  4. Shared Game Data: Game files are downloaded once and shared via volume

API Endpoints

The steam-auth HTTP API exposes:

GET /health

Health check endpoint.

json
{
  "status": "ok",
  "logged_in": true,
  "timestamp": "2026-01-16T12:00:00.000Z"
}

GET /steam/app-ticket

Get an encrypted app ticket for GOG Galaxy authentication.

json
{
  "app_ticket": "base64-encoded-ticket",
  "steam_id": "76561198012345678"
}

Available Commands

The steam-auth service supports several commands:

CommandDescription
setupInteractive login + download game (first-time setup)
loginInteractive login only, saves session
downloadDownload/update game files (uses saved session)
ticketOutput encrypted app ticket to stdout
export-tokenExport saved refresh token for CI use
serveRun HTTP API for runtime ticket requests (default)

Token Persistence

Refresh tokens are saved to /data/steam-session/session-{username}.json and reused on container restart. Steam tokens typically last 200 days.

How Invite Codes Work

  1. Game server starts hosting via GOG Galaxy SDK
  2. Galaxy creates a lobby and requests an encrypted app ticket
  3. Game server's AuthService fetches ticket from steam-auth via HTTP
  4. Steam-auth uses SteamKit2 to get an encrypted app ticket from Steam
  5. Ticket is returned and used to generate an invite code with "G" prefix

Lobbies are forced Public

An invite code is just the lobby's raw ID handed to JoinLobby. Stardew's default visibility is FriendsOnly (Game1.options.serverPrivacy), which restricts joins to friends of the host account — but a dedicated server's host account is not friended by its players, so the default would block invite-code joins for everyone. The mod therefore forces both lobbies Public regardless of the game-chosen value:

TransportSite
Steam lobbyGalaxyAuthService.SetSteamLobbyPrivacy (hardcodes "public")
Galaxy (GoG) lobbyServerOptimizerOverrides.CreateLobby_Prefix (forces ServerPrivacy.Public)

This is why a JunimoServer lobby is always Public and can appear in the Steam/GOG lobby list. The two sites must stay in sync; changing one alone splits the transports' privacy.

Auth vs Transport: What the Sidecar Replaces (and What It Can't)

The sidecar fully replaces the Steam client for authentication — but Steam network sessions are a separate layer, and the distinction decides which flows can run headless.

An encryptedAppTicket is a static, signed identity proof designed to be shown to third parties. GOG's backend accepts it as a login (GalaxyInstance.User().SignInSteam(ticket)), so every Galaxy-side feature runs headless with sidecar tickets. Steam's own relay network (SDR) is different: relays only carry traffic for endpoints holding live session certificates, and Valve opens such sessions through exactly two doors:

DoorAPI familyHeadless?Identity granted
User sessionSteamAPI.Init() → attaches to a running Steam client processNoThe account's personal Steam64 (7656…)
GameServer sessionSteamGameServer.Init() + LogOnAnonymous()Yes (by design, for dedicated servers)An ephemeral gameserver ID (90…), no account involved

There is no API that turns a ticket into a Steam network session — deliberately, since otherwise any headless process could impersonate a live user endpoint (the unforgeability the mod's farmhand-ownership gate relies on).

Consequences in this codebase:

  • The server never needs a user session: SDR listening uses the GameServer door (SteamGameServerNetworkingSockets, Callback.CreateGameServerSteamGameServerNetServer.cs), the Steam lobby is managed by the sidecar's SteamKit2 account session (GameServer mode has no SteamMatchmaking), and Galaxy hosting uses a sidecar ticket. Steam credentials exist for the ticket/lobby plumbing, not for SDR.
  • A vanilla client dialing SDR is the one operation in the whole system gated on the user door. The decompiled chain (decompiled/sdv-1.6.15-24356/StardewValley/SDKs/Steam/): SteamHelper.cs:60 SteamAPI.Init() (attach to the Steam client; the app ticket itself is then requested from the logged-on user session) → SteamNetClient.cs:204SteamMatchmaking.JoinLobby:163 SteamMatchmaking.GetLobbyGameServer (resolves the host's gameserver ID) → :131 SteamNetworkingSockets.ConnectP2P — all user-mode APIs with no GameServer-mode counterpart for a player connection.
  • Test clients therefore authenticate fully (real account, real ticket, real Galaxy logon — same chain as the server) but transport over Galaxy P2P: the test-client mod redirects Hybrid (S-prefix invite) joins to GalaxyNetClient (tests/test-client/Auth/ClientAuthService.cs, CreateClient_Prefix). Client-side SDR is never dialed in the harness; the server's SDR listener runs but is only exercised in production. A GameServer-door dialer for the test client (real SDR traffic under a gameserver identity) is planned in .claude/plans/features/tests-sdr-client-transport.md.

Which identity a joining player presents (per door)

The door a player joins through decides the transport identity their connection carries — and therefore which identity the farmhand-ownership map records:

Join pathTransportIdentity the server sees
Friends list / S-prefix invite codeSteam SDR (SN_… connection id)The account's Steam64 (7656…)
G-prefix invite codeGalaxy P2P (GN_… connection id)The account's GOG Galaxy uint64
Direct IPLidgren LAN (L_… connection id)None

The same Steam account therefore presents two unrelated ids depending on the door: the Steam64 and the Galaxy pseudo-id share no id space and cannot be correlated server-side (client-declared userID stamps are Galaxy-space on both platform transports, never the Steam64). A player who owns a farmhand through one door and rejoins through the other is rejected by the ownership gate; the operator fix is farmhand rebind with the id from the new door's connect-log line. ConnectionTransport (mod) is the canonical parser for these connection-id shapes.

File Filtering

The download process skips unnecessary files to reduce download size:

  • Large audio files (Wave Bank.xwb ~370MB) — always stripped (the server runs silent)
  • Non-English language files (~50MB) — stripped by default; opt back in per language with STEAM_KEEP_LANGUAGES (see below)
  • Other assets not needed for dedicated server operation

This reduces the download from ~1.5GB to ~600MB.

After filtering, Content/ContentHashes.json is pruned to list only the files that were actually downloaded. The game checks this manifest (not the filesystem) to decide whether an asset exists, so leaving a stripped file listed would make the game attempt to load a missing .xnb and throw ContentLoadException. Pruning keeps the manifest honest so the game's built-in localized→English font fallback works.

Keeping a language's fonts

By default every localized font/content file is stripped and the server falls back to the English font, so non-Latin chat (e.g. Cyrillic, CJK) renders as empty boxes on clients. To keep a language's fonts in the download — so that language renders correctly — set STEAM_KEEP_LANGUAGES to a comma-separated list of language codes:

bash
STEAM_KEEP_LANGUAGES=pt-BR,ru-RU

Valid codes: de-DE, es-ES, fr-FR, hu-HU, it-IT, ja-JP, ko-KR, pt-BR, ru-RU, tr-TR, zh-CN, th-TH. CJK codes (zh-CN, ja-JP, ko-KR) also keep their larger font families, so those add more to the image size than the others. An unrecognized code is logged as a warning and ignored.

CI/CD Usage

For automated builds, use a refresh token instead of interactive login:

bash
# Export token after local setup
docker compose run steam-auth export-token > token.json

# Use in CI (set as secret)
STEAM_REFRESH_TOKEN=xxx STEAM_USERNAME=user docker compose run steam-auth download

Environment Variables

Steam Auth Container

VariableDescriptionDefault
STEAM_USERNAMESteam account username(prompted)
STEAM_PASSWORDSteam account password(prompted)
STEAM_REFRESH_TOKENPre-existing refresh token (for CI)-
PORTHTTP server port3001
SESSION_DIRToken storage directory/data/steam-session
GAME_DIRGame files directory/data/game
FORCE_REDOWNLOADSet to "1" to re-download all files-
STEAM_KEEP_LANGUAGESComma-separated language codes whose fonts to keep in the download (e.g. pt-BR,ru-RU). Default strips all localized content (English-only).-

Game Server Container

VariableDescriptionDefault
STEAM_AUTH_URLURL of steam-auth servicehttp://steam-auth:3001

Released under the MIT License.