Application identification and access¶
Signal Fish Server can restrict WebSocket use to a configured set of public
application IDs. This is an allowlist and accounting boundary, not client
authentication: shipped clients send app_id in cleartext, and any client that
knows an allowed value can reuse it.
The compiled default enforces the allowlist. The example configuration disables it for local development.
Configure the app-ID allowlist¶
Set enforce_app_id_allowlist to true and register each allowed application:
{
"security": {
"enforce_app_id_allowlist": true,
"allowed_apps": [
{
"app_id": "my-game",
"app_name": "My Game",
"max_rooms": 100,
"max_players_per_room": 16,
"rate_limit_per_minute": 60
}
]
}
}
The two policy modes are:
false: open mode. Clients may omitAuthenticate; any supplied app ID gets the default limits.true: allowlist mode. The first client message must beAuthenticate, and its publicapp_idmust appear inallowed_apps.
By default the app-ID handshake stays credential-free: app_id is a public
label. Deployments that need protocol-level tenant authentication can enable
the optional connect_token mode — see
Optional tenant connect tokens below.
Frozen wire names¶
Protocol v2 is frozen and protocol v3 is additive, so the existing wire names
remain Authenticate, Authenticated, and AuthenticationError. In this
server they mean “submit an app label,” “label accepted and protocol negotiated,”
and “handshake rejected”; the label itself never proves the caller's identity.
When the optional connect_token mode is enabled, the signed token — not the
label — is the credential.
const ws = new WebSocket('wss://signal.example/v2/ws');
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'Authenticate',
data: { app_id: 'my-game' }
}));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'Authenticated') {
// The public app label was accepted; room operations may now begin.
}
};
If another message arrives first in allowlist mode, the server sends a generic
MISSING_APP_ID error and closes the connection. Unknown IDs receive
INVALID_APP_ID. The same code rejects IDs that cannot be accepted safely in
operator-facing logs — control characters such as newlines or ANSI escapes, or
lengths over 256 bytes — in every mode. A protocol maximum below the deployment
minimum receives UNSUPPORTED_PROTOCOL_VERSION.
Exact trust boundary¶
Once an app ID is accepted, the server attaches its application context to the connection. Room creation, seated joins, spectator joins, reconnects, ready state, quotas, and per-app rate limits all use that connection-bound context; later messages cannot claim a different app ID.
This provides accounting and accidental-collision isolation only. A client that knows another allowed ID can:
- consume that label's rate and room quota;
- create rooms attributed to that label;
- join that label's rooms when it also knows their room codes; and
- appear in logs and metrics under that label.
Room ownership remains non-enumerating: a different label receives the same
ROOM_NOT_FOUND result for seated, spectator, and reconnect admission. That
does not turn the public label into a credential.
What the application UUID means¶
The connection-bound application context carries an internal UUID, and its
provenance differs by policy. Under the enforced allowlist it is always a
deterministic SHA-256 derivative of the public app ID string, so nothing about
it is client-chosen. Under the open policy it is derived the same way but
hashed under an open-policy-only namespace (issue #518), so an open-mode UUID
does not equal a configured application's UUID and a client cannot claim
another application's identity by sending that application's UUID as its
app_id. The same label still always yields the same UUID, so attribution and
room membership stay stable across handshakes and restarts.
Open-mode application identity does scope room admission (issue #520): a
created room is stamped with the creator's application UUID, and an owned
room admits only same-application seats, spectators, and reconnects. That
boundary remains a soft one — an attacker who knows (or guesses) a victim's
public app label can present it and pass the gate — so it must not be
treated as a tenancy guarantee. Deployment-grade isolation requires
allowlist enforcement plus the optional connect_token mode, where a
control-plane-signed credential is verified before admission.
Per-app settings¶
app_id— public identifier sent by clients.app_name— human-readable name returned after the handshake.max_rooms— maximum concurrent persisted rooms owned by the label across all game names.max_players_per_room— maximum requested capacity for newly created rooms.rate_limit_per_minute— handshake requests per minute, counted across every connection using the same public ID. Enforced only when an entry configures an explicit value; omitting it is the "unlimited" configuration — theAuthenticated.rate_limitsnumbers are then projections only, and unknown-ID rejections never consume any budget. Enforcement is split into two sliding windows: the application-wide ceiling above, plus a per-source (IP) share of half that budget (at least one) — so one source that knows a configuredapp_idcan never continuously exhaust the app's budget and lock out legitimate handshakes (issue #502). Rejected app-ID resolutions consume no budget in either window, including concurrent rejections. The server charges both limits in one decision. A successful resolution still spends budget if later tenant credential verification fails. Note a single source can therefore admit at most half of the advertisedper_minutefigure (for an entry limited to 1 per minute, the share is that single handshake); clients sharing one NAT egress share that source budget. A botnet spanning many sources is bounded by the application-wide ceiling itself.max_relay_bytes— optional per-sender game-data relay byte budget for this application (issue #530): admitted relay payload charges draw from this budget instead of the server-widerate_limit.max_relay_bytesdefault, so hosted tiers can bound tenants independently. The fixed rate-limit window stays anchored to the sender, so a client cannot reset its window by switching app labels; must be> 0when set.
Reload the allowlist at runtime (SIGHUP)¶
Allowlist enforcement is on or off for the life of the process, but the
configured set of applications is reloadable without a restart (issue #522).
Send SIGHUP to the server process:
On each SIGHUP the server:
- Re-reads the configuration from the same sources as startup
(
config.json,security.app_auth_path,SIGNAL_FISH_CONFIG_JSON, environment overrides). - Validates the new
security.allowed_appsset with the exact startup rules (unique IDs, log-safe IDs, registry-file contract). - Swaps the set atomically. New handshakes resolve against the new set; handshakes already in flight complete against the set they resolved.
- Logs the change (
added/removedapp IDs).
Failure behavior:
- A configuration that fails to load or fails security validation keeps the running allowlist. The error is logged. Validation covers the whole config document, so an unrelated invalid edit (a removed TLS cert file, for example) also blocks the allowlist update. To force fail-closed admission, restart the process instead.
- Removing an application stops NEW handshakes for that label immediately. Connections that already resolved it keep their context; revoking a live connection stays a restart or tenant-level action. The kept context includes the admission powers the context carries: a pre-reload socket can still join rooms and create new rooms under the revoked application's identity until it disconnects. New handshakes — including any reconnect from a fresh socket — fail with the unknown-app-ID refusal.
- A reload that TIGHTENS a per-app cap (
max_rooms,max_players_per_room) reaches fresh handshakes immediately. Live sockets keep their resolved context until they re-handshake (reconnect from a new socket), so the stricter limit can coexist with rooms created by pre-reload sockets until those sockets recycle. The room-count check itself is always live; only the limit value is snapshotted. - A reload can introduce rate limits for the first time; new budgets apply to new handshakes at once.
- In open mode (
enforce_app_id_allowlist: false) the reload is a logged no-op: there is no configured set to swap.
Only security.allowed_apps and the security.connect_token verification key
are applied live. Port, TLS, limits, and every other configuration field still
require a restart; the reload log says so.
External app-registry file (security.app_auth_path)¶
For deployments where a control plane owns the live app registry (onboarding,
suspension, per-app caps), the registry can live outside the main config
document. Set security.app_auth_path to a JSON file of the shape
{"apps": [...]} where each entry has exactly the same fields as an
allowed_apps entry:
{
"apps": [
{
"app_id": "my-game",
"app_name": "My Game",
"max_rooms": 100,
"max_players_per_room": 16,
"rate_limit_per_minute": 60
}
]
}
The file's entries are appended to security.allowed_apps at config load
time, before any enforcement or --validate-config checks, so the two lists
form one registry. Semantics:
- Fail-closed admission. When the knob is set, a missing, unreadable, malformed, or contract-violating file is a startup error naming the file — never a silent fall-back to the config-file subset. A deployment whose registry mount disappears must fail loudly, not quietly start rejecting every handshake (or accounting for none).
- Strict keys. Unknown keys — at the top level, or inside any entry — are
rejected, so a typo'd cap fails loudly instead of silently widening.
app_secretis rejected outright: the registry is a public-label list and must never carry credential material. (The optionalconnect_tokenverification key is server-side public configuration, not a client credential, and it lives undersecurity.connect_token, never in the registry.) - No duplicates.
app_idcollisions between the two lists (or within one list) are startup validation errors naming the duplicate entry (allowed_apps[N].app_id). - Empty is valid.
{"apps": []}loads no entries — the shape a control plane writes for a freshly provisioned host.
The path can also be supplied without a config document via the environment:
This is the intended contract for the cloud deployment's read-only-mounted
/etc/signal-fish/app-auth.json file: the provisioner regenerates the file on
its own schedule and the server picks it up at the next startup or at the
next SIGHUP reload (see "Reload the allowlist at runtime"). At startup the
fail-closed rule applies: a missing or unreadable registry refuses to boot.
At reload time the running allowlist is kept and the error is logged, so a
transiently missing mount cannot silently change admission; restart to force
the fail-closed refusal.
Optional tenant connect tokens¶
The connect_token mode adds protocol-level tenant authentication
(issue #517). The operator's control plane signs tokens with an Ed25519
private key; the server verifies them against the matching public key. The
private key never reaches this process, and verification is stateless: no
callouts, no store.
Configure¶
{
"security": {
"connect_token": {
"public_key": "<base64 of the 32-byte Ed25519 public key>"
}
}
}
- Set exactly one of
public_key(inline base64) orpublic_key_path(a file whose trimmed contents are the same base64 key; the loader folds the file intopublic_key). Configuring both is a startup error. - The key is public material. The path option exists for mount and rotation convenience, not secrecy.
- A key that does not parse (not base64, not 32 bytes, not a valid curve point) or has low order is a startup error. Use a public key derived from an Ed25519 signing key. The server verifies token signatures strictly.
- The key reloads on
SIGHUPwith the allowlist. A reload with a corrupt key keeps the running key; removing the block removes verification, and presented tokens are then refused.
Require tokens (issue #574)¶
Verification alone rejects bad tokens. To also reject missing ones, arm the global default:
{
"security": {
"connect_token": {
"public_key": "<base64 of the 32-byte Ed25519 public key>",
"required": true
}
}
}
- With
required: true, anAuthenticatewithout aconnect_tokenis refused withCONNECT_TOKEN_REQUIREDinstead of falling back to public-app_idsemantics. Applications whose released SDKs never send the field stop authenticating — roll the flag out per tenant first if you host several. - The per-tenant rollout uses the allowlist entry:
require_connect_tokenon anallowed_appsentry overrides the global default for that one application (trueenforces a single app under a lax deployment,falseexempts one app from an enforcing deployment). Registry changes reload onSIGHUPwith the allowlist. - Open-policy mode (
enforce_app_id_allowlist: false) has no registry, so only the global default reaches it. Enforcement there also closes the legacy skip-Authenticatepath: an application frame sent beforeAuthenticateis refused withMISSING_APP_ID, and a socket that never authenticates is closed with4001 auth_timeout— released clients that skip the handshake stop working. - A
require_connect_token: trueentry with no verification key configured is a startup error (and a rejected SIGHUP reload): enforcement would refuse every handshake for that app with no remedy. - The posture reloads on
SIGHUPtogether with the key. Removing the key block disarms enforcement; a reload that would strip the key while an entry still requires tokens is rejected wholesale, and the running deployment keeps both. - The refusal is retryable and budget-charged like every handshake refusal: the connection stays open, and the client can fetch a token and retry.
Wire contract¶
A client that holds a credential adds one optional field to Authenticate:
Token format (minted by the control plane):
token = "sfct_v1" "." payload_b64 "." signature_b64
payload = UTF-8 JSON {"app_id": string, "exp": unix-seconds, "nonce": string}
Both base64 encodings are URL-safe without padding. The 64-byte Ed25519
signature covers the exact ASCII bytes of "sfct_v1." ++ payload_b64 — the
encoded form is signed, so there is no JSON canonicalization anywhere.
The payload must be one JSON object. Arrays, duplicate claim fields, and
non-integer expiry values are refused. Unknown fields are allowed.
The decoded app_id must match exactly. The nonce must contain 1 to 128
UTF-8 bytes.
The server verifies, in order: encoding, signature, claim structure, expiry,
remaining validity, then that the payload's app_id equals the presented app_id. Every failure
reports the same error code, CONNECT_TOKEN_INVALID; the error text carries
the reason, and the token is never logged or echoed.
Semantics and limits¶
- Absent field. No token: the handshake behaves exactly as before,
unless the deployment enforces the credential (see
Require tokens). An enforcing deployment
refuses the token-less handshake with
CONNECT_TOKEN_REQUIRED. - Presented without a configured key. Refused with
CONNECT_TOKEN_INVALID(fail closed). A client that expects credentials to matter must not be silently downgraded to public-label semantics. - Remaining validity. The server accepts a token only while
expis in the future and its remaining validity is at most 300 seconds plus a fixed 60-second clock-skew allowance. A token outside this window is refused now but can become valid later. The token has no issue-time claim; this limit does not bound time since minting. These checks use the server's wall clock. Token expiry does not close a connection that already authenticated. - Replay is accepted within the window. The server is stateless and does
no single-use tracking. Send tokens only over TLS. A leaked token replays
until expiry — this is the ratified trade-off. Use the signed
nonceat the control plane or edge if single-use enforcement is needed. - Retryable refusal. A rejected token keeps the connection open, so the
client can fetch a fresh token and re-send
Authenticateon the same socket. The refusal charges the per-connection error-reply budget, like every polite per-frame reply (close code4006bounds the total). - Redaction. The server never logs the token and never echoes it — not in
Authenticated,ProtocolInfo, room snapshots, or reconnect payloads. Client SDKs must apply the same rule to their own logs.
Legacy configuration¶
Existing configuration remains loadable:
require_websocket_authaliasesenforce_app_id_allowlist.authorized_appsaliasesallowed_apps.app_secretis accepted only as deprecated input and discarded without being retained, logged, validated, or emitted by--print-config.
Migrate to the canonical names. Supplying both a canonical and legacy name in
the same JSON source is a startup error naming the source and both keys, so
the server never boots on an ambiguous allowlist and no lower-priority open
config can fail open.
Canonical individual-field environment overrides still have final precedence.
Duplicate app_id entries — and entries that could never authenticate (control
characters such as newlines or ANSI escapes, or more than 256 bytes) — are
rejected at startup rather than using last-entry-wins limits or failing every
later handshake silently. The canonical environment override is:
SIGNAL_FISH__SECURITY__ENFORCE_APP_ID_ALLOWLIST=true
SIGNAL_FISH__SECURITY__ALLOWED_APPS='[{"app_id":"my-game","app_name":"My Game","rate_limit_per_minute":60}]'
Handshake timeout¶
websocket.auth_timeout_secs is also a frozen legacy name. It is the exclusive
deadline for receiving the initial Authenticate protocol-negotiation frame.
Input observed at or after the boundary is rejected with close code
4001 auth_timeout.
Metrics authentication¶
Metrics authentication is separate and does validate a real bearer secret:
Generate the token through a secret manager or environment variable. The server
compares Authorization: Bearer <metrics_auth_token> in constant time and
redacts the configured token from --print-config.
Operational guidance¶
- Use
wss://in production so app labels and signaling are protected in transit. - Treat rejected-ID spikes as probing or configuration drift.
- Treat allowed IDs as public; do not grant billing, administrative, or secret access based on them.
- Protect active TURN and metrics secrets through environment or secret-manager injection.
See Configuration, Deployment, and Error codes.