Skip to main content

Keys and credentials

SlotBattle has four credentials. They share no code, no storage and no failure modes, so that adding authentication to a new route cannot pick the wrong one by proximity.

CredentialWho holds itWhere it travelsRotatable
Tenant API keyYour casino backendAuthorization: BearerIssue + revoke
Console sessionAn operator's browserHttpOnly cookieExpiry + logout
Viewer tokenA viewer's browserWebSocket subprotocolSigning key, with a window
Recorder callback secretThe recorderHMAC over the request bodyRedeploy

1. Tenant API key

The server-to-server credential, and the one your backend uses.

Authorization: Bearer sbk_3f9a1c...

Format. Prefix sbk_ followed by 64 hex characters (32 random bytes).

Storage. Only the SHA-256 hash is persisted, plus the first 12 characters (sbk_ + 8 hex) for display in the console. The plaintext is shown once, at creation. There is no way to display it again: losing it means issuing a replacement and revoking the old key.

Scopes. Each key carries its own set, checked per route:

ScopeGrants
games:readRead the game catalog
battles:readRead battles
battles:writeOpen and manage battles
bots:writeSeat bots
viewer:mintMint viewer tokens
demo:mintMint a demo game session. Evaluation only, never the production flow
bots:write is separate from battles:write

Seating bots spends the casino's own provider sessions, which is a different act from opening a battle. A key that only opens battles cannot silently fill them with bots. Issue the narrowest set that works.

Issuing. From the console (Casino → API keys), or by your host if your account does not carry apikeys.write. Revocation is immediate.

The tenant is derived from the key, never sent alongside it. See Tenancy.

2. Console session

The credential for the admin console.

It is an HttpOnly cookie scoped to /admin, with SameSite=Strict, carrying a signed session token. The token never appears in a response body, and Authorization: Bearer is not accepted on the console surface. A bearer token within JavaScript's reach can be read by an XSS and replayed indefinitely; a cookie the script cannot read is bounded by the browser.

SameSite=Strict constrains your DNS

SameSite=Strict reasons about the registrable domain. The console and the API must share one: panel.example.com and api.example.com work; panel.example.net and api.example.com do not.

Split across registrable domains, every authenticated request fails silently. The browser does not attach the cookie, nothing logs an error, and the requests come back unauthenticated. Decide this before buying the domain.

There is no registration route. Your first operator account is created for you by the platform. Optional second factors, TOTP with recovery codes and email magic links, sit on top.

3. Viewer token

Short-lived, battle-scoped, and the only credential that reaches an untrusted browser.

What it is. A JWT signed with Ed25519, audience slotbattle-ws, scoped to one battle, valid for 15 minutes.

How it travels. Not in the URL, but in the WebSocket subprotocol header:

Sec-WebSocket-Protocol: slotbattle.jwt, <token>

A token in a query string lands in access logs, proxy logs and browser history. The subprotocol is the only browser-reachable way to send a credential on a WebSocket handshake, since browsers cannot set arbitrary headers there.

How you get one. Creating a battle and adding a seat both return one in the response. If it expired, which happens on a lobby that sat open for twenty minutes, mint a fresh one with POST /battles/{id}/viewer-tokens, which costs viewer:mint.

Rotating the signing key

A viewer token lives 15 minutes and is already in the browser of everyone currently watching. Replacing the key outright invalidates all of them at once, disconnecting every viewer of every running battle.

Rotation therefore appoints a new key and leaves the previous one verifying until it retires:

The kid in each token's header is what lets one verifier hold both keys. A retirement window shorter than a token's own lifetime is refused, because it would strand tokens that are still valid.

Exactly one key is active at a time, enforced by a database constraint rather than by service logic that two concurrent rotations could race.

Rotation is a platform operation, super-user only: one key signs for every casino, so it is not a resource any single casino owns.

Bootstrap only

The signing key environment variable is a bootstrap value. It applies only while the key table is empty. Once a key exists, rotation is the only way to change it, and editing the environment variable has no effect.

4. Recorder callback secret

Internal. The recorder signs each seat's terminal callback with HMAC over the raw request body, and the signature is the only credential on that route; there is no tenant auth on it.

You never handle this one. It lives between the platform's control plane and its recorders, and it is listed here so that recorder in a log line is not mistaken for a credential you were meant to receive.

Every credential act is attributed

Issuing a key, revoking a key and rotating the signing key all record who did it, as the actor's address at the time, snapshotted rather than linked.

A foreign key to a users table would cascade or null out when that user is deleted, erasing the record. The actor always comes from the authenticated session, never from the request body; naming one in the payload is a 400.

A key issued outside the console has no signed-in user and reports CLI / unknown rather than a blank, because an empty cell reads as a rendering fault.

Practical guidance

Give each consumer its own key. One key per backend service, scoped to what that service does. Revoking a shared key takes every consumer down at once.

Never let an API key reach a browser. It is a server-to-server credential with a casino-wide blast radius. The tutorial proxies every call through server-side handlers for this reason. See Watch a battle live.

Mint viewer tokens per viewer, per battle. They are cheap, short and scoped. Reusing one across battles is not possible, and reusing one across viewers discards the only revocation granularity available.

Next