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.
| Credential | Who holds it | Where it travels | Rotatable |
|---|---|---|---|
| Tenant API key | Your casino backend | Authorization: Bearer | Issue + revoke |
| Console session | An operator's browser | HttpOnly cookie | Expiry + logout |
| Viewer token | A viewer's browser | WebSocket subprotocol | Signing key, with a window |
| Recorder callback secret | The recorder | HMAC over the request body | Redeploy |
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:
| Scope | Grants |
|---|---|
games:read | Read the game catalog |
battles:read | Read battles |
battles:write | Open and manage battles |
bots:write | Seat bots |
viewer:mint | Mint viewer tokens |
demo:mint | Mint a demo game session. Evaluation only, never the production flow |
bots:write is separate from battles:writeSeating 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 DNSSameSite=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.
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
- Authentication: the API key on the wire, and error responses
- WebSocket: the viewer token handshake in full
- Signing keys: rotating from the console