Device registration and lifecycle
How a user becomes a set of devices, who allocates device IDs, and what registration, provisioning, unlink, and account reset each own.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 10 min
A user represents a changing set of devices, not an account row. Signal Protocol
sessions address individual devices. The SDK therefore scopes each key, session,
and envelope to a (userId, deviceId) pair. Your product defines the account
above that pair.
This fact controls the complete lifecycle. A reinstalled phone becomes a different device for the same user. A linked tablet receives a new cryptographic identity with no previous sessions. An account reset destroys device-local key material. No server can restore that material.
Construct a client with a device identity
createSignalProtocolClient takes the device identity up front. It is not mutable afterwards.
import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
const client = await createSignalProtocolClient({
identity: { userId, deviceId: 1 },
adapters: { storage, relay },
});deviceId is optional and defaults to 1, the primary device. A single-device
product can omit it. Add an explicit value before the product supports another
device.
Let the backend allocate device IDs
The backend allocates linked device IDs from 2 through 5. A client must not
select its own linked-device ID. Each user can have at most five devices: one
primary and four linked devices.
If two devices both select deviceId: 2, they overwrite each other's prekeys.
They also receive envelopes for the other device. The resulting session state
cannot converge. The relay contract assigns linked-device slot allocation to the
server.
Provision a linked device before you construct its client. Write the provisioned
identity material before you call SignalProtocolClient.create(..., { deviceId: 2 }).
An empty store does not bootstrap a linked device. Instead, it creates an identity
that the rest of the account does not recognize.
The device-ID cache
Store the allocated ID in platform secure storage so it survives process restarts. Read it through the device-ID module, not the client.
import {
clearDeviceIdCache,
getDeviceId,
getDeviceIdSync,
preloadDeviceId,
} from '@open-e2ee/signal-protocol-sdk/device/device-id';
await preloadDeviceId();
const deviceId = await getDeviceId();getDeviceIdSync() returns the cached value or the primary-device default. Call
it after preloadDeviceId() resolves. Otherwise, use it only where a fallback to
device 1 is safe. A synchronous read before preload returns 1 on a linked
device. All dependent calls then address the wrong device.
What syncToServer() publishes
await client.syncToServer();This call publishes the device's public identity material and prekeys. It also replenishes low one-time-prekey inventory. After the call, peers can fetch a bundle and send envelopes to the device. Before the call, the relay cannot fan messages out to it.
Afterward, client.syncStatus reports 'synced' | 'failed' | 'none'. The device
sends public key material and routing metadata. The relay never needs message
plaintext or device private keys. The device generates private halves and keeps
them in the local store. See Relay and prekeys for
replenishment values and the rotation schedule.
DeviceLifecycleManager
DeviceLifecycleManager provides a framework-neutral registration workflow. It
persists the device ID, compares it with the backend registry, detects stale or
orphaned devices, and clears local state.
import {
DeviceLifecycleManager,
type DeviceLifecycleDeps,
} from '@open-e2ee/signal-protocol-sdk/device/lifecycle';
const lifecycle = new DeviceLifecycleManager(userId, deps);
await lifecycle.initialize();DeviceLifecycleDeps injects a secure store and a backend client with query and
mutation. It also injects your generated function references, key-storage
operations, and logger. The manager forwards those references. It does not select
a backend. It exposes initialize(), registerDevice(), fetchDevices(),
loadStoredDeviceId(), checkDeviceStale(), makePrimary(), and
clearLocalDevice().
The application remains responsible for authenticated backend functions and for placing initialization in its own startup lifecycle.
One lifecycle, four operations
Registration, provisioning, unlink, and account reset are four points on one product-level lifecycle, not four independent features.
Registration claims a device slot and publishes key material.
Provisioning adds a linked device. It transfers account identity material and optional account metadata, but not existing sessions or message history. A provisioning session expires after five minutes. Ephemeral ECDH keys derive its encryption keys. The source device encrypts identity material before transport.
Unlink removes a slot and must remain consistent across active identity types.
Account reset must clear the device-ID cache, platform secret storage, and protocol store as one product-level lifecycle.
Do not clear only the protocol store with client.clearAllData(). If the cached
device ID and vault wrapping key remain, the device reports a linked ID. It also
holds a key for a database that no longer exists. The device then generates a new
identity under an occupied slot. Clear all three resources, or clear none.
The device encrypts its name for backend storage. The relay holds
encryptedDeviceName bytes. The device decrypts the plaintext label for its local
device list.
Authentication is yours
The SDK does not provide login, session tokens, or an account model.
Backend
authentication must bind registration, provisioning, unlink, and removal to the
owning account. Do not accept a client-supplied userId for a relay write. That
design lets any authenticated caller register another user's device.
The SDK interface guide defines this responsibility boundary:
"the client owns protocol coordination; the host application owns persistence, authentication, authorization, and product policy."
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
identityKeyPair private half | yes | no | no | no |
identityKeyPair public half | yes | yes | no | yes |
compositeIdentity (canonical CompositeIdentityV1) | yes | yes | no | yes |
deviceId (cached under DEVICE_ID_KEY) | yes | yes | no | yes |
registrationId | yes | yes | no | yes |
encryptedDeviceName | yes | yes | no | ciphertext length |
| Plaintext device name | yes | no | no | no |
SessionRecord (version: 4) | yes | no | no | no |
Provisioning ephemeralKeyPair private half | yes | no | no | no |
Provisioning sessionId | yes | yes | no | yes |
"signal-store-wrapping-key" vault entry | yes | no | no | no |
Next
- Local encrypted storage: the store and vault this lifecycle clears
- Relay and prekeys: what
syncToServer()publishes and how often - Multi-device: provision a linked device end to end
- Identity change and safety numbers: show a user when a device identity changes
Build
The application architecture above the protocol, ordered by the sequence the decisions actually arrive in — from device registration through to the recovery policy you cannot avoid choosing.
Local encrypted storage
Why the storage adapter is required, what the four adapters actually support, and why the vault and the store must never be blurred.