Sigil nodes expose two WebSocket endpoints. This runbook covers the private one. For the public read-only /ws, see Subscriptions.

Why two endpoints

Conflating public read-only subscriptions and identity-bound subscriptions on a single endpoint is the canonical auth-confusion bug pattern. The split:
  • /ws — public, no auth, read-only topics (newBlock, newTx, consensus).
  • /ws-private — validator-profile-only, bearer-token authenticated, identity-bound topics.
A pod running with --rpc-profile public always returns 404 for /ws-private, regardless of token. Identity-bound subscriptions never leave the validator surface.

Auth model

Current: bearer token

/ws-private accepts the Authorization: Bearer <token> header and matches <token> against the SIGIL_WS_PRIVATE_TOKEN env var using a constant-time comparison. If the env var is unset, the endpoint returns 503 (fail-closed) so a forgotten secret cannot accidentally expose identity-bound subscriptions.

Future: mTLS or DID-signed handshake

The bearer-token check is a transitional posture. Future releases swap it for either:
  • mTLS: the client presents a certificate signed by the operator’s intermediate CA. ingress-nginx terminates and validates the cert; the node sees the resulting x-ssl-client-dn header.
  • OAS DID-signed challenge: the server emits a random nonce on connect; the client signs it with the OAS DID’s Ed25519 key; the server verifies against the published public key.
The endpoint surface remains stable so client code and operator tooling do not break across the migration.

Operator setup

Generate a token

# 32-byte random token, base64url-encoded.
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='

Provision via Kubernetes Secret

apiVersion: v1
kind: Secret
metadata:
  name: sigil-ws-private
  namespace: sigil-platform
type: Opaque
stringData:
  token: "<output from above>"
Bind the secret to the validator deployment env:
env:
  - name: SIGIL_WS_PRIVATE_TOKEN
    valueFrom:
      secretKeyRef:
        name: sigil-ws-private
        key: token
Public RPC pods do not receive this env var — the endpoint is gated by profile before the token check, so omitting the secret on public pods is the correct posture.

Smoke-test the endpoint

# Public profile (rpc.sigil.ml) — must 404 regardless of token.
curl -sI -H "Connection: upgrade" -H "Upgrade: websocket" \
  -H "Authorization: Bearer $SIGIL_WS_PRIVATE_TOKEN" \
  https://rpc.sigil.ml/ws-private | head -1
# Expected: HTTP/2 404

# Validator profile (in-cluster), no token — must 401.
kubectl -n sigil-platform exec deploy/sigil-val-1 -- \
  curl -sI -H "Connection: upgrade" -H "Upgrade: websocket" \
  http://localhost:26657/ws-private | head -1
# Expected: HTTP/1.1 401

# Validator profile, correct token — must 101 Switching Protocols.
kubectl -n sigil-platform exec deploy/sigil-val-1 -- \
  curl -sI -H "Connection: upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Authorization: Bearer $SIGIL_WS_PRIVATE_TOKEN" \
  http://localhost:26657/ws-private | head -1
# Expected: HTTP/1.1 101

Rotating the token

NEW=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')

kubectl -n sigil-platform patch secret sigil-ws-private \
  --type=merge \
  -p "{\"stringData\":{\"token\":\"$NEW\"}}"

kubectl -n sigil-platform rollout restart deployment/sigil-val-1 deployment/sigil-val-2 deployment/sigil-val-3
Distribute the new token to clients via a secure channel and revoke cached copies. Treat rotations like cloud access keys: rotate on departure, on suspected compromise, and on a quarterly cadence.

Constant-time compare

The token check uses an explicit constant-time byte comparison in node/sigil-node/src/ws.rs::ct_eq. A naïve == on &[u8] short-circuits on the first mismatch and leaks the matching prefix length via timing. The current implementation iterates the full length and accumulates XOR results.

Topics

TopicTodayFuture
newBlockyes (mirrors /ws)yes
newTxyes (mirrors /ws)yes
consensus (redacted)yesyes
account.<did>not yetyes
contract.<address>not yetyes
consensus.detailednot yetyes
The future identity-bound topics require per-DID and per-contract event emission from the engine, which lands in a follow-on release.

See also