Steam Cloud Pipeline Integration Guide
Overview
This guide explains how to integrate Steam authentication with Loreguard Cloud to enable player-authenticated NPC chat. Instead of embedding API tokens in your game client (which would be insecure), you exchange Steam session tickets for short-lived Player JWTs.
Architecture
┌──────────────────────────────────────────────────────────────────────┐
│ YOUR GAME CLIENT │
│ ┌─────────────────┐ │
│ │ Steam SDK │──── GetAuthSessionTicket() ─────┐ │
│ └─────────────────┘ │ │
│ ▼ │
│ ┌─────────────────┐ POST /api/player/steam ┌─────────────────┐ │
│ │ HTTP Client │─────────────────────────────▶│ Loreguard API │ │
│ └─────────────────┘ {app_id, ticket} └────────┬────────┘ │
│ │ │ │
│ │◀──────────── Player JWT ────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ POST /api/chat ┌─────────────────┐ │
│ │ NPC Chat │─────────────────────────────▶│ Loreguard API │ │
│ │ Authorization: Bearer <JWT> └─────────────────┘ │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Flow:
- Game client obtains Steam session ticket from Steamworks SDK
- Client exchanges ticket for Player JWT via
/api/player/steam - Loreguard validates ticket with Steam Web API
- Client uses JWT in
Authorization: Bearerheader for NPC chat
Setup Steps
1. Register Your Steam App
Before your game can authenticate players, you need to register your Steam AppID:
- Go to Loreguard Dashboard > Developers > Steam Apps
- Click "Add Steam App"
- Enter your Steam AppID (e.g.,
480for Spacewar test app) - Map it to a Title ID (used for analytics and rate limiting)
- Enable the app for production
2. Exchange Steam Ticket for Player JWT
Unity C# Example
using Steamworks;
using UnityEngine;
using UnityEngine.Networking;
using System;
using System.Collections;
using System.Text;
public class LoreguardSteamAuth : MonoBehaviour
{
private const string LOREGUARD_API = "https://api.loreguard.com";
private string _playerJwt;
private DateTime _jwtExpiresAt;
[Serializable]
private class SteamExchangeRequest
{
public string app_id;
public string ticket;
}
[Serializable]
private class SteamExchangeResponse
{
public string token;
public string expires_at;
public string player_id;
public string studio_id;
public string title_id;
}
public IEnumerator AuthenticateWithSteam()
{
// Get Steam auth ticket
byte[] ticketData = new byte[1024];
uint ticketSize;
HAuthTicket authTicket = SteamUser.GetAuthSessionTicket(
ticketData,
ticketData.Length,
out ticketSize
);
if (authTicket == HAuthTicket.Invalid)
{
Debug.LogError("Failed to get Steam auth ticket");
yield break;
}
// Convert to base64
string ticketBase64 = Convert.ToBase64String(ticketData, 0, (int)ticketSize);
// Exchange for Loreguard JWT
var request = new SteamExchangeRequest
{
app_id = SteamUtils.GetAppID().ToString(),
ticket = ticketBase64
};
string json = JsonUtility.ToJson(request);
byte[] bodyRaw = Encoding.UTF8.GetBytes(json);
using (var www = new UnityWebRequest($"{LOREGUARD_API}/api/player/steam", "POST"))
{
www.uploadHandler = new UploadHandlerRaw(bodyRaw);
www.downloadHandler = new DownloadHandlerBuffer();
www.SetRequestHeader("Content-Type", "application/json");
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"Steam auth failed: {www.error}");
yield break;
}
var response = JsonUtility.FromJson<SteamExchangeResponse>(
www.downloadHandler.text
);
_playerJwt = response.token;
_jwtExpiresAt = DateTime.Parse(response.expires_at);
Debug.Log($"Authenticated as Steam player: {response.player_id}");
}
// Cancel the Steam ticket (we have our JWT now)
SteamUser.CancelAuthTicket(authTicket);
}
public bool IsAuthenticated =>
!string.IsNullOrEmpty(_playerJwt) && DateTime.UtcNow < _jwtExpiresAt;
public string GetAuthHeader() => $"Bearer {_playerJwt}";
}
Godot GDScript Example
extends Node
const LOREGUARD_API = "https://api.loreguard.com"
var player_jwt: String = ""
var jwt_expires_at: int = 0
signal auth_completed(success: bool)
signal auth_error(message: String)
func authenticate_with_steam() -> void:
# Get Steam auth ticket
var ticket_dict = Steam.getAuthSessionTicket()
if ticket_dict.is_empty():
emit_signal("auth_error", "Failed to get Steam auth ticket")
return
var ticket_id = ticket_dict["id"]
var ticket_buffer = ticket_dict["buffer"]
# Convert to base64
var ticket_base64 = Marshalls.raw_to_base64(ticket_buffer)
# Exchange for Loreguard JWT
var http = HTTPRequest.new()
add_child(http)
http.request_completed.connect(_on_steam_exchange_completed.bind(http, ticket_id))
var body = JSON.stringify({
"app_id": str(Steam.getAppID()),
"ticket": ticket_base64
})
var headers = ["Content-Type: application/json"]
var error = http.request(
LOREGUARD_API + "/api/player/steam",
headers,
HTTPClient.METHOD_POST,
body
)
if error != OK:
emit_signal("auth_error", "HTTP request failed: " + str(error))
http.queue_free()
func _on_steam_exchange_completed(
result: int,
response_code: int,
headers: PackedStringArray,
body: PackedByteArray,
http: HTTPRequest,
ticket_id: int
) -> void:
http.queue_free()
# Cancel Steam ticket (we have our JWT now)
Steam.cancelAuthTicket(ticket_id)
if response_code != 200:
emit_signal("auth_error", "Steam exchange failed: " + str(response_code))
return
var json = JSON.parse_string(body.get_string_from_utf8())
if json == null:
emit_signal("auth_error", "Invalid JSON response")
return
player_jwt = json.get("token", "")
var expires_at_str = json.get("expires_at", "")
# Parse ISO8601 expiry
jwt_expires_at = Time.get_unix_time_from_datetime_string(expires_at_str)
print("Authenticated as Steam player: ", json.get("player_id"))
emit_signal("auth_completed", true)
func is_authenticated() -> bool:
return player_jwt != "" and Time.get_unix_time_from_system() < jwt_expires_at
func get_auth_header() -> String:
return "Bearer " + player_jwt
3. Use Player JWT for NPC Chat
Unity C# Example
public IEnumerator ChatWithNPC(string characterId, string message)
{
if (!IsAuthenticated)
{
yield return AuthenticateWithSteam();
}
var request = new
{
character_id = characterId,
message = message
};
string json = JsonUtility.ToJson(request);
byte[] bodyRaw = Encoding.UTF8.GetBytes(json);
using (var www = new UnityWebRequest($"{LOREGUARD_API}/api/chat", "POST"))
{
www.uploadHandler = new UploadHandlerRaw(bodyRaw);
www.downloadHandler = new DownloadHandlerBuffer();
www.SetRequestHeader("Content-Type", "application/json");
www.SetRequestHeader("Authorization", GetAuthHeader());
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"Chat failed: {www.error}");
yield break;
}
var response = JsonUtility.FromJson<ChatResponse>(www.downloadHandler.text);
Debug.Log($"NPC says: {response.speech}");
}
}
Godot GDScript Example
func chat_with_npc(character_id: String, message: String) -> void:
if not is_authenticated():
await authenticate_with_steam()
await auth_completed
var http = HTTPRequest.new()
add_child(http)
http.request_completed.connect(_on_chat_completed.bind(http))
var body = JSON.stringify({
"character_id": character_id,
"message": message
})
var headers = [
"Content-Type: application/json",
"Authorization: " + get_auth_header()
]
http.request(LOREGUARD_API + "/api/chat", headers, HTTPClient.METHOD_POST, body)
func _on_chat_completed(
result: int,
response_code: int,
headers: PackedStringArray,
body: PackedByteArray,
http: HTTPRequest
) -> void:
http.queue_free()
if response_code == 429:
# Rate limited
var json = JSON.parse_string(body.get_string_from_utf8())
print("Rate limited: ", json.get("error"))
return
if response_code != 200:
print("Chat failed: ", response_code)
return
var json = JSON.parse_string(body.get_string_from_utf8())
print("NPC says: ", json.get("speech"))
Rate Limits
Player JWTs are subject to rate limiting to ensure fair usage:
| Limit Type | Value | Scope |
|---|---|---|
| Per Player | 60 requests/minute | Per studio:player pair |
| Per Studio | Configurable | All players combined |
| Steam Exchange | 30 requests/minute | Per IP address |
| Steam Exchange | 30 requests/minute | Per AppID |
When rate limited, you'll receive a 429 Too Many Requests response:
{
"error": "rate_limited",
"limit_type": "player",
"requests_used": 60,
"requests_limit": 60,
"reset_at": "2025-01-01T12:01:00Z"
}
Security Notes
Player JWT Properties
- Expiry: 30 minutes (automatically set by server)
- Algorithm: Ed25519 (EdDSA) - quantum-resistant
- Claims: StudioID, TitleID, PlayerID (Steam ID), Scope
- Scope:
chat- only allows NPC chat endpoints
Best Practices
- Never embed API tokens in game clients - Use Steam exchange instead
- Refresh JWTs proactively - Re-authenticate before expiry
- Handle 401 gracefully - Re-authenticate on token rejection
- Don't store raw Steam tickets - Exchange immediately, then discard
Family Sharing Support
Loreguard supports Steam Family Sharing when enabled by the studio:
| Setting | Behavior |
|---|---|
ALLOW_STEAM_FAMILY_SHARING=false |
Only game owners can authenticate (default) |
ALLOW_STEAM_FAMILY_SHARING=true |
Family sharing users can authenticate |
When family sharing is enabled:
- The
player_idin the JWT will be the borrower's Steam ID - The original owner's Steam ID is available in server logs for analytics
- Rate limits apply to the borrower, not the owner
To enable family sharing, contact support or configure it in your Steam App settings in the dashboard.
Troubleshooting
"Steam ticket validation failed"
- Ensure your AppID is registered in the Loreguard dashboard
- Check that the AppID matches your game's actual Steam AppID
- Verify the ticket is base64-encoded correctly
- Steam tickets expire quickly - exchange within seconds of obtaining
"App not found or not enabled"
- Your Steam AppID is not registered, or is disabled
- Go to Dashboard > Developers > Steam Apps to enable it
"Invalid player token"
- The JWT has expired (30 minute lifetime)
- Re-authenticate with Steam to get a fresh JWT
"Rate limit exceeded"
- You're sending too many requests
- Implement exponential backoff
- Consider caching NPC responses locally
API Reference
POST /api/player/steam
Exchange Steam session ticket for Player JWT.
Request:
{
"app_id": "480",
"ticket": "base64-encoded-steam-ticket"
}
Response (200 OK):
{
"token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
"expires_at": "2025-01-01T12:30:00Z",
"player_id": "76561198012345678",
"studio_id": "studio_abc123",
"title_id": "my-game"
}
Error Responses:
400 Bad Request- Missing or invalid parameters401 Unauthorized- Steam ticket validation failed404 Not Found- App not registered or disabled429 Too Many Requests- Rate limited
POST /api/chat
Chat with an NPC using Player JWT authentication.
Headers:
Authorization: Bearer <player_jwt>
Content-Type: application/json
Request:
{
"character_id": "merchant-npc",
"message": "Hello, what do you sell?",
"player_id": "optional-override",
"current_context": "The player is in the market district."
}
Response (200 OK):
{
"speech": "Ah, a customer! I have potions, scrolls, and rare artifacts.",
"verified": true,
"citations": [
{"claim": "potions, scrolls, and rare artifacts", "evidence_id": "E1", "verified": true}
],
"latency_ms": 245
}