Auth & Join Game Flow¶
Overview¶
The join flow spans three distinct phases before a player lands in-world:
- Authentication (
L_ClientAuth) — Login or register, obtain a session JWT, connect to Socket.IO. - Character Selection (
L_CharacterSelection) — Pick or create a character, then request to join a game session. - Server Gate Travel — The backend finds or creates a session, issues a single-use JWT, and the client travels to the game server which verifies the token before allowing the connection.
L_ClientAuth
│ REST login → session JWT → Socket.IO connected
▼
L_CharacterSelection
│ JOIN_GAME socket event → backend session logic → single-use JWT
▼
SERVER_GATE_TRAVEL received
│ Client travels to game server URL
▼
Game Server (MIPControlChannel)
│ NMT_Login intercepted → backend verifies single-use JWT
▼
Player enters world
Phase 1 — L_ClientAuth¶
L_ClientAuth is the entry map (main menu). Its sole job is to authenticate the player and establish a persistent Socket.IO connection.
Flow¶
User submits credentials
│
▼
POST /users/login (REST)
│ Response: session JWT
▼
Connect to Socket.IO (Authorization: Bearer <jwt>)
│
▼
Emit CHARACTER_SELECTION request
│
▼
Receive CharacterSelection event
│ Payload: account, family, characters[]
▼
K2_OnClientAuthCompleted()
│
▼
Open level L_CharacterSelection
Detail¶
1. REST Login — POST /users/login is called with the player's credentials. On success the response contains a session JWT that is valid for the duration of the session (long-lived). On failure the player sees an error and stays on the auth screen.
2. Socket.IO Connection — The session JWT is attached as the authorization credential when opening the Socket.IO connection. This connection is kept alive for the entire client session.
3. CHARACTER_SELECTION Request — Once the socket is connected, the client emits MIPSocketIOStatics::CHARACTER_SELECTION. The backend replies with the full account, family, and character list for this account.
4. K2_OnClientAuthCompleted — Called after the CharacterSelection response is received and processed. Implementations open L_CharacterSelection.
UFUNCTION(BlueprintImplementableEvent, DisplayName="OnClientAuthCompleted")
void K2_OnClientAuthCompleted();
Phase 2 — L_CharacterSelection¶
This map handles character creation and game entry.
New Account — Character Creation¶
When an account has no characters the player is prompted to create one:
- Choose a class.
- Name the family (family name is account-wide — only set once).
- Name the character.
Once created, the character appears in the selection list and the player can proceed to join.
Joining the Game¶
Selecting a character and clicking Join emits MIPSocketIOStatics::JOIN_GAME to the backend.
Emit JOIN_GAME { characterId }
│
▼
Backend: verify account / family / character exist
│
▼
Determine map
├─ Existing character → last known area map + transform
└─ New character → STARTING_MAP_NAME
│
▼
Find or create session for that map
├─ No session found → create new session, add player to awaiting list
└─ Session found → select least-crowded valid session
│
▼
Generate single-use JWT (TTL 120 s, Redis whitelisted)
│
▼
Emit SERVER_GATE_TRAVEL → client
Backend Validation — The backend confirms the account, family, and character all exist and belong to each other before proceeding. Any failure stops the flow and returns an error to the client.
Map Determination — If the character has a saved lastAreaMap and lastTransform that are valid, those are used. Otherwise STARTING_MAP_NAME is used (starter zone for new characters).
Session Selection — If no session exists for the target map, a new one is created and the player is added to the session's awaiting players list. Once the session signals readiness, a travel request is dispatched to all awaiting players simultaneously. If sessions exist, the backend picks the one with the lowest player count that is not over the crowded threshold, then dispatches a travel request to the requesting client only.
Single-Use JWT Token¶
Before dispatching SERVER_GATE_TRAVEL, the backend generates a single-use JWT bound to this join attempt.
| Property | Value |
|---|---|
expiresIn |
120 seconds |
| Redis key | playerSessionId (from token payload) |
| Redis TTL | 120 seconds |
Why single-use?¶
If the player receives the travel URL but never connects (crash, network drop, etc.), the token must not be reusable. Two independent expiry mechanisms enforce this:
- JWT
expiresIn— the token is cryptographically invalid after 120 s even if Redis has been cleared. - Redis whitelist —
verifySingleUseJoinGameToken()extractsplayerSessionIdfrom the decoded payload and checks Redis. If the key exists it is deleted immediately, invalidating the token for any future attempt. If the key is already gone (already used or TTL elapsed) the check fails.
verifySingleUseJoinGameToken(token)
│
├─ Decode JWT → verifies expiresIn (<120 s elapsed?)
│ └─ Fail: token expired → reject
│
└─ Check Redis for playerSessionId
├─ Not found → already used or TTL expired → reject
└─ Found → DELETE from Redis (one-time consumption) → accept
Warning
Both checks must pass. A valid JWT with an already-consumed Redis key is still rejected.
Phase 3 — SERVER_GATE_TRAVEL¶
The backend emits SERVER_GATE_TRAVEL to the client with the game server connection details.
Client-side handling¶
Receive SERVER_GATE_TRAVEL
│
▼
GateTravelEventCallback()
│ Validates: server URL is non-empty, JWT is non-empty
▼
Set NextSessionInstanceTravelUrl
Set NextSessionInstanceTravelOptions (contains the single-use JWT)
│
▼
Broadcast OnReceivedSessionInstanceTravelDelegate
│
▼ (bound in AMIPCharSelectionGameMode::BeginPlay)
UMIPBaseGameInstance::BeginSessionInstanceMapTravel()
│
▼
ClientTravel(NextSessionInstanceTravelUrl, NextSessionInstanceTravelOptions)
NextSessionInstanceTravelOptions is the UE travel option string. The single-use JWT is embedded here so it travels with the connection request to the game server.
Phase 4 — Game Server JWT Verification¶
The game server intercepts the Unreal Engine login handshake via a custom Control Channel before the player is allowed to fully connect.
Custom Control Channel¶
DefaultEngine.ini overrides the built-in Control Channel:
[/Script/OnlineSubsystemUtils.IpNetDriver]
!ChannelDefinitions=ClearArray
+ChannelDefinitions=(ChannelName=Control, \
ClassName=/Script/ModularInventoryPlus.MIPControlChannel, \
StaticChannelIndex=0, bTickOnCreate=true, \
bServerOpen=false, bClientOpen=true, \
bInitialServer=false, bInitialClient=true)
UMIPControlChannel overrides ReceivedBunch() and intercepts the NMT_Login message before it reaches the engine:
NMT_Login Interception¶
When UMIPControlChannel receives an NMT_Login message, it routes it through PreLoginChecks to verify the single-use JWT before allowing the connection to proceed.
PreLoginChecks Flow¶
PreLoginChecks(NMT_Login, Bunch)
│
├─ Extract JWT from NextSessionInstanceTravelOptions
│ └─ JWT empty? → Connection->Close(SecurityInvalidData)
│
▼
Emit VERIFY_JOIN_GAME_TOKEN to Socket.IO backend
│
├─ Response = 0 (fail) → Connection->Close(SecurityInvalidData)
│
└─ Response = 1 (success)
│
▼
NotifyControlMessage(Connection, NMT_Login, InBunch)
│
▼
UWorld::NotifyControlMessage()
│
▼
GameMode->PreLogin(Options, RemoteAddress, PlayerId, ErrorMsg)
│
▼
Player fully admitted to the server
Step 1 — Extract JWT — PreLoginChecks reads the JWT from the travel options string that arrived with the NMT_Login packet. If the string is empty or missing, the connection is closed immediately with Connection->Close(ENetCloseResult::SecurityInvalidData).
Step 2 — Backend Verification — The server emits VERIFY_JOIN_GAME_TOKEN over its own Socket.IO connection to the backend. The backend runs verifySingleUseJoinGameToken() (JWT expiry + Redis one-time check).
| Value | Meaning | Action |
|---|---|---|
1 |
Token valid | Call NotifyControlMessage → continue login |
0 |
Token invalid / expired / already used | Connection->Close(SecurityInvalidData) |
Step 3 — Normal Login Continues — On success, NotifyControlMessage hands off to UWorld::NotifyControlMessage, which calls the standard engine login path (GameMode->PreLogin).
Step 4 — Local RS256 Check (inside PreLogin) — AMIPBaseGameMode::GetPlayerInfoFromJwtToken is called with the connection options string. It calls VerifyPlayerJoinJwtIfConfigured(jwt):
- If the dedicated server was initialized with an RS256 public key (via Agones annotation
JWT_PUBLIC_KEY_PEM_B64), the token signature is verified locally without a network round-trip. - If no key is configured (dev / PIE), the check passes through.
- On failure,
FMIPPlayerInfois returned empty and the login aborts.
On success the JWT payload is decoded and FMIPPlayerInfo is populated (family, character, class, player session, etc.). From here the player proceeds through the normal Unreal Engine join flow (PostLogin, pawn spawn, etc.).
Defense-in-depth
The backend VERIFY_JOIN_GAME_TOKEN step (Step 2) consumes the Redis single-use key — it is the primary gate. Step 4 independently verifies the cryptographic signature without a network round-trip. Both must succeed; neither substitutes for the other.
Complete Flow Diagram¶
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT BACKEND / GAME SERVER │
│ │
│ [L_ClientAuth] │
│ POST /users/login ──────────────────────────► REST API │
│ ◄───────────────────────── session JWT │
│ │
│ Socket.IO connect (bearer JWT) ─────────────► Socket.IO │
│ Emit CHARACTER_SELECTION ───────────────────► │
│ ◄───────────────────────── account+chars │
│ K2_OnClientAuthCompleted() │
│ Open L_CharacterSelection │
│ │
│ [L_CharacterSelection] │
│ Emit JOIN_GAME { characterId } ─────────────► Socket.IO │
│ ├ verify account │
│ ├ resolve map │
│ ├ find/create │
│ │ session │
│ └ generate │
│ single-use JWT│
│ ◄───────────────────────── SERVER_GATE_TRAVEL
│ { url, jwt } │
│ GateTravelEventCallback() │
│ Set TravelUrl + TravelOptions (jwt) │
│ ClientTravel ───────────────────────────────► Game Server │
│ │
│ [Game Server — MIPControlChannel] │
│ NMT_Login intercepted │
│ PreLoginChecks() │
│ Emit VERIFY_JOIN_GAME_TOKEN ────────────────► Socket.IO │
│ ◄───────────────────────── 1 (valid) │
│ NotifyControlMessage(NMT_Login) │
│ GameMode->PreLogin() │
│ GetPlayerInfoFromJwtToken() │
│ VerifyPlayerJoinJwtIfConfigured() ← local RS256 check │
│ Decode JWT payload → FMIPPlayerInfo │
│ ► Player enters world │
└─────────────────────────────────────────────────────────────────┘