Skip to content

Auth & Join Game Flow

Overview

The join flow spans three distinct phases before a player lands in-world:

  1. Authentication (L_ClientAuth) — Login or register, obtain a session JWT, connect to Socket.IO.
  2. Character Selection (L_CharacterSelection) — Pick or create a character, then request to join a game session.
  3. 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 LoginPOST /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:

  1. Choose a class.
  2. Name the family (family name is account-wide — only set once).
  3. 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:

  1. JWT expiresIn — the token is cryptographically invalid after 120 s even if Redis has been cleared.
  2. Redis whitelistverifySingleUseJoinGameToken() extracts playerSessionId from 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:

virtual void ReceivedBunch(FInBunch& Bunch) override;

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 JWTPreLoginChecks 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, FMIPPlayerInfo is 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                                          │
└─────────────────────────────────────────────────────────────────┘