Connection Tickets
Browsers cannot set an Authorization header on a WebSocket. Putting the access token in the URL would leak a long-lived credential into proxy and server logs. Connection tickets solve it: the client trades its access token (an authenticated HTTP call) for a one-time ticket valid for seconds, and opens the socket with ?ticket=. The portal's [TicketShield] redeems it once.
The Flow
Setup
1. Issue tickets
Write the endpoint yourself — it is where your rules go (banned accounts, a tutorial first, a maintenance window):
[ApiController, Route("api"), Authorize]
public sealed class TicketController(IConnectionTicketService tickets) : ControllerBase
{
[HttpPost("ticket")]
public async Task<IActionResult> Ticket(CancellationToken ct)
{
var id = User.PrincipalId() ?? throw new UnauthorizedAccessException();
var t = await tickets.IssueAsync(id, groupKey: User.PrincipalName(), ct);
return Ok(new { ticket = t.Ticket, expiresIn = t.ExpiresInSeconds });
}
}
2. Shield the portal
[Portal("/game")]
[TicketShield]
public class GamePortal : Portal, OnConnectedAsync
{
public Task OnConnectedAsync(string clientId, ConnectionManager manager, AltruistConnection connection)
{
var principalId = connection.AuthDetails!.PrincipalId; // from the ticket
var groupKey = connection.AuthDetails.GroupKey; // the groupKey you issued (default: the principal id)
...
}
}
The upgrade request is rejected with 401 unless it carries a valid ticket. A ticket works once: a second connection with it, or one after it expired, is refused.
3. Connect
const { ticket } = await (await fetch("/api/ticket", { method: "POST", headers: { Authorization: `Bearer ${accessToken}` } })).json();
const socket = new WebSocket(`wss://${location.host}/game?ticket=${encodeURIComponent(ticket)}`);
Config
altruist:
security:
tickets:
query-param: ticket # ?ticket=
ttl-seconds: 30
max-per-principal: 4 # outstanding tickets; older ones are dropped
session-hours: 12 # AuthDetails lifetime of the connection
redact-request-logs: true
Fleets
Tickets live in the fleet backplane (IFleetBackplane): with Redis, a ticket issued by one server is redeemed by any other — the client may land anywhere. Redemption is an atomic take, so two connects racing with one ticket (even on different servers) cannot both succeed. With one server the backplane is in memory.
Secrets in Logs
Only the SHA-256 of a ticket is stored. But ASP.NET's request log lines (Request starting GET /game?ticket=...) contain the full URL. When the app uses [TicketShield], Altruist keeps only warnings of that log category (Microsoft.AspNetCore.Hosting.Diagnostics); set redact-request-logs: false to keep the request lines.