Skip to content

Client Integration Guide

This section provides production-ready guides for connecting to the Signal WebSocket server. There are two separate client implementations — they share nothing with each other:

ClientLanguageCryptoWebSocketGuide
Web BrowserTypeScript/JavaScriptWeb Crypto API (crypto.subtle)window.WebSocketWeb Browser Guide
Tauri AndroidRust (main process)p256 + aes-gcm + hkdf cratestokio-tungsteniteTauri Android Guide

Also see: Cross-Service Integration & Testing

Connection Lifecycle

Steps in detail:

#PhaseWhat happensTimeout
1CONNECTINGnew WebSocket(url) opens TCP connectionBrowser default (~30s)
2CONNECTEDonopen fires. Client generates ECDH P-256 key pair.--
3AUTHENTICATINGClient sends authenticate with { type: "Bearer", token, clientPublicKey }Server closes after 5 seconds if no authenticate received
4ECDH HandshakeServer verifies JWT, generates its own ECDH key pair, derives AES-256-GCM key with random 32-byte HKDF salt--
5AUTHENTICATEDServer sends connected { id, userId, time, serverPublicKey, salt }. Client imports server public key, derives same AES key using server's salt.--
6EncryptedAll subsequent messages wrapped as { event: "encrypted", data: { iv, ct } }. Heartbeat every 30s (plaintext).Heartbeat timeout: 90 seconds
7Disconnectonclose fires. AES key is deleted. Client can auto-reconnect (new ECDH key pair each time = forward secrecy).--

Reconnection Behavior

When the WebSocket connection drops unexpectedly, the client automatically reconnects with forward secrecy:

StepWhat happens
1Connection drops → onclose fires
2cleanup() — delete AES key, ECDH key pair, stop heartbeat
3Wait interval * backoffMultiplier^(retryCount-1) ms (exponential backoff)
4connect() — generate new ECDH key pair (forward secrecy)
5Open new WebSocket, send authenticate with same JWT token + new clientPublicKey
6Server generates new server ECDH key pair, derives new AES key with new random salt
7Client derives new AES key from new server public key + new salt
8Auto-join configured rooms — rooms are re-subscribed automatically
9retryCount resets to 0 on successful authentication

Exponential backoff example (defaults: interval=3000, multiplier=1.5, max=30000):

Retry #Delay
13,000 ms
24,500 ms
36,750 ms
410,125 ms
515,187 ms
6+30,000 ms (capped)

After maxRetries (default: 5) consecutive failures, the client emits reconnect_failed and stops. The application can listen for this:

typescript
client.on('reconnect_failed', () => {
  showToast('Connection lost. Please check your network and refresh.');
});

Room-Based Messaging Patterns

Rooms enable targeted message delivery. The server encrypts messages per-client automatically via the outbound transformer.

RoomPurposeJoined by
ws-defaultGeneral notificationsAll authenticated clients
ws-ordersOrder status updatesPOS clients, admin dashboard
ws-notificationsPush notificationsAll clients
ws-paymentPayment events (success, fail, refund)POS clients
ws-test-paymentPayment testingDevelopment only

Pattern: Subscribe on mount, unsubscribe on unmount

typescript
// Join rooms when the component mounts
useEffect(() => {
  if (client?.isConnected()) {
    client.joinRooms(['ws-orders']);
  }
  return () => {
    client?.leaveRooms(['ws-orders']);
  };
}, [client?.isConnected()]);

Pattern: Auto-join via configuration

typescript
// Rooms specified at construction time are auto-joined on connect and re-joined on reconnect
const client = new EncryptedWebSocketClient({
  url: '...',
  ecdhInfo: '...',
  token: '...',
  autoJoinRooms: ['ws-default', 'ws-orders', 'ws-notifications'],
});

REST API Examples

The REST API is used by backend services to send messages to WebSocket clients. The server handles encryption automatically — REST callers send plaintext, and the outbound transformer encrypts for each target client.

Check Server Status (no auth required)

bash
curl http://localhost:3500/v1/api/socket/websocket/clients/status

# Response: { "isReady": true, "clientCount": 5 }

List Connected Clients

bash
curl http://localhost:3500/v1/api/socket/websocket/clients \
  -H "Authorization: Bearer <jwt-token>"

# Response: { "clients": [{ "id": "abc", "state": "authenticated", "encrypted": true, ... }] }

Broadcast to All Clients

bash
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/broadcast \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt-token>" \
  -d '{ "topic": "order.updated", "data": { "orderId": "12345" } }'

Send to a Room

bash
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/rooms/ws-orders/send \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt-token>" \
  -d '{ "topic": "order.new", "data": { "orderId": "67890" } }'

Send to a Specific Client

bash
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/{clientId}/send \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt-token>" \
  -d '{ "topic": "notification", "data": { "message": "Your order is ready" } }'

Force Disconnect a Client

bash
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/{clientId}/disconnect \
  -H "Authorization: Bearer <jwt-token>"

Debugging & Troubleshooting

SymptomCauseFix
connected event received but no encryptionServer sent connected without serverPublicKey / saltCheck requireEncryption: true in NxWebSocketComponent. Ensure APP_ENV_WEBSOCKET_ECDH_INFO is set on the server.
Decryption fails after successful handshakeECDH info mismatch between client and serverVerify VITE_SIGNAL_ECDH_INFO matches APP_ENV_WEBSOCKET_ECDH_INFO exactly (case-sensitive).
Connection closes immediately (code 4004)Missing clientPublicKey in authenticate payloadEnsure generateECDHKeyPair() is called before connecting and publicKeyB64 is sent.
Connection closes after 5 seconds (code 4001)Client didn't send authenticate in timeCheck that onopen handler sends authenticate immediately.
Connection closes after 90 secondsMissing heartbeatVerify heartbeat is running every 30 seconds. Check startHeartbeat() is called after connected.
WebSocket error with no detailsNetwork issue, server down, or TLS errorCheck server is running. For wss://, verify TLS certificate is valid. Check firewall/proxy rules.
Messages received but handlers not calledEvent name mismatchThe decrypted inner payload has { event, data }. Verify the event name matches what the server sends (e.g., payment.success not payment-success).
Reconnect loop — authenticates then immediately disconnectsExpired JWT tokenCall client.updateToken(newToken) with a fresh token before/during reconnect.
Cannot send — encryption not establishedSending before ECDH handshake completesWait for state === 'authenticated' before calling send(). Use the connected event handler.

Enable debug logging:

typescript
const client = new EncryptedWebSocketClient({
  url: '...',
  ecdhInfo: '...',
  token: '...',
  logger: {
    info: (...args) => console.log('[Signal]', ...args),
    warn: (...args) => console.warn('[Signal]', ...args),
    error: (...args) => console.error('[Signal]', ...args),
    debug: (...args) => console.debug('[Signal]', ...args),
  },
});

Security Considerations

RuleDetail
Never log AES keysCryptoKey objects are non-extractable by design. Do not attempt to serialize or log them.
Never reuse ECDH key pairsGenerate a new key pair for every connection (including reconnects). This provides forward secrecy.
Never skip saltAlways use the server-provided salt from the connected event. Without it, the client and server derive different AES keys.
Token storageStore JWT tokens securely. In browser: httpOnly cookies preferred, sessionStorage acceptable. Avoid localStorage for sensitive tokens. In Tauri: use the Tauri secure storage plugin or OS keychain.
Validate originThe Signal server should validate WebSocket upgrade origins in production to prevent cross-site WebSocket hijacking.
TLS in productionAlways use wss:// (WebSocket Secure) in production. Plain ws:// is acceptable only for local development.
Key cleanup on disconnectEncryptedWebSocketClient.cleanup() sets aesKey = null and ecdhKeyPair = null on every disconnect. This ensures session keys cannot be used after disconnection.

Important Notes Summary

TopicDetail
ECDH Info must matchThe HKDF info string on the client must match APP_ENV_WEBSOCKET_ECDH_INFO on the server. Mismatched values produce different AES keys and decryption will fail.
HKDF Salt must matchThe client must use the salt value from the connected event. The server generates a random 32-byte salt during deriveAESKey() and includes it in the handshake response.
JWT token requiredObtain a valid JWT from the Identity service (@nx/identity) before connecting. Authentication without a valid token is rejected.
Encryption is mandatoryAfter handshake, all application messages must use event: "encrypted". The outbound transformer encrypts all server-to-client messages automatically.
Forward secrecyEach connection generates a new ephemeral ECDH key pair. Session keys are never reused across reconnections.
HeartbeatSend { event: "heartbeat" } every 30 seconds to prevent idle timeout (default heartbeat timeout: 90 seconds).
Auth timeoutClients have 5 seconds to send the authenticate event after connecting, or they are disconnected.
Plaintext eventsauthenticate, connected, error, heartbeat, join, leave are always sent in plaintext. All other events are encrypted.
Room re-joinAfter reconnection, rooms must be re-joined. The auto-join rooms configuration (autoJoinRooms in TypeScript, auto_join_rooms in Rust) handles this automatically.
Token refreshCall client.updateToken(newToken) if the JWT is refreshed. The new token is used on the next reconnect.

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.