Multi-device
Linking a second device, the difference between provisioning and transfer, and why a new device cannot read messages that predate it.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 12 min
Every Alice-and-Bob tutorial stops at two devices that both existed before the first message. Real products do not work that way. Alice buys a tablet in March, and her phone dies in July. Both events affect session state that started its ratchet in January. This page covers those events.
The device model
A user is not an account row. A user is a changing set of devices, each of which is its own cryptographic identity with its own sessions.
The primary device uses deviceId 1. The backend allocates linked device IDs
from 2 through 5. A client must not choose its own linked-device ID.
That gives a ceiling of five devices: one primary and four linked. The relay owns
slot allocation because it is the only party that can see the whole set and
enforce uniqueness against an authenticated account.
Sesame is the specification that governs this. The SDK selects SESAME's per-user identity-key model and provisions that tuple unchanged across linked devices. Registration IDs, prekeys, and sessions are device-specific. Sesame is Revision 2, dated 2017-04-14. It is the least-implemented member of the Signal Protocol family. As far as we are aware, no other JavaScript library implements it. This SDK therefore offers a distinct capability.
Provisioning is not transfer
These are two deliberately separate operations and confusing them is the most expensive mistake available on this page.
| Provisioning | Transfer | |
|---|---|---|
| Purpose | Add a linked device | Move to replacement hardware |
| Moves identity material | yes | yes |
| Moves prekeys | no | yes |
| Moves sessions | no | yes |
| Moves message history | no | via encrypted backup, if you built one |
| Subpath | /device/provisioning | /device |
Provisioning adds a peer. Transfer replaces one. A user might tap "add my tablet" and expect their archive to appear. Provisioning does not move that archive and never intended to do so.
Linking a device
The primary device opens a short-lived session and renders the QR:
import {
generateProvisioningQR,
provisionDevice,
} from '@open-e2ee/signal-protocol-sdk/device/provisioning';
const { sessionId, qrCodeUrl, ephemeralKeyPair } =
await generateProvisioningQR(relay, userId);
await appQr.show(qrCodeUrl);
const { newDeviceEphemeralPublicKey } =
await appProvisioning.waitForLinkedDevice(sessionId);
await provisionDevice(
relay,
appProfile,
sessionId,
ephemeralKeyPair.privateKey,
newDeviceEphemeralPublicKey,
userId,
{ identityStore, groupStateStore },
);The new device scans it, joins the session, and stores the encrypted result:
import {
connectToProvisioningSession,
getDeviceMetadata,
parseProvisioningQR,
receiveProvisioningMessage,
} from '@open-e2ee/signal-protocol-sdk/device/provisioning';
const { sessionId, primaryEphemeralPublicKey } = parseProvisioningQR(scannedQrCode);
const deviceMetadata = getDeviceMetadata("Alice's tablet");
const linkedKeys = await connectToProvisioningSession(relay, sessionId, deviceMetadata);
const provisioning = await receiveProvisioningMessage(
relay,
sessionId,
linkedKeys.privateKey,
primaryEphemeralPublicKey,
{
identityStore,
localStateStore,
groupStateStore,
usernameStateStore,
deviceMetadata,
},
);
console.log(provisioning.deviceId); // allocated by the backend, 2–5Only then can the linked device construct a client. Linked devices must already
contain provisioned identity material before SignalProtocolClient.create(..., { deviceId: 2 }).
Calling it first gives you a device holding a different identity than the account
it claims to belong to.
Security boundaries
- Provisioning sessions expire after five minutes.
- Ephemeral ECDH keys derive the provisioning/transfer encryption keys.
- Devices encrypt identity and backup material before it reaches a relay or transport.
- Devices encrypt names for backend storage.
- Backend authentication must bind registration, provisioning, unlink, and removal to the owning account.
- Account reset must clear the device-ID cache, platform secret storage, and protocol store as one product-level lifecycle.
The QR channel authenticates the session only to the extent that your application protects what the user scans or shares. A screenshot in a group chat exposes a provisioning invitation. Only the five-minute expiry prevents that invitation from authorising an unwanted linked device.
Keeping linked devices in step
Sessions are per-device. A linked device can contradict the primary if it does not receive a read, block, or verification decision. The SDK ships explicit sync operations for state that has no other path between devices:
await signal.syncReadToLinkedDevices(/* … */);
await signal.syncConfigurationToLinkedDevices(/* … */);
await signal.syncVerificationStateToLinkedDevices(/* … */);
await signal.syncBlockedRecipientsToLinkedDevices(blocked);Also available: syncViewOnceOpenToLinkedDevices, syncMediaAttachmentDeleteToLinkedDevices,
syncUsernameStateToLinkedDevices, syncRecipientUsernameToLinkedDevices, and
syncTaskNotificationAckToLinkedDevices. Each is a message you choose to send.
None of them fire on their own.
Transfer to replacement hardware
If a new device replaces an old device instead of joining it, use the transfer path:
import {
prepareNewDeviceTransfer,
prepareOldDeviceTransferWithBackup,
} from '@open-e2ee/signal-protocol-sdk/device';
const receiving = await prepareNewDeviceTransfer();
await appQr.show(receiving.qrCode);
const sending = await prepareOldDeviceTransferWithBackup(backupStorage);
const backup = await sending.getBackup(sessionIds);The application owns transport selection, peer confirmation, progress UI, interruption recovery, and wiping old-device state after a successful replacement. Do not erase the old device before the new device validates and durably restores the backup. That ordering is the whole safety property, and it is yours to enforce.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
ephemeralKeyPair private half | yes | no | no | no |
ephemeralKeyPair public half | yes | yes | no | yes |
sessionId from generateProvisioningQR() | yes | yes | no | yes, plus five-minute lifetime |
qrCodeUrl | yes | no | no | no |
| Provisioned identity material | yes | encrypted | no | that a provisioning envelope moved |
deviceMetadata device name | yes | encrypted | no | that a name exists |
provisioning.deviceId | yes | yes | no | yes — the relay allocates it |
| Registration IDs, prekeys, sessions | yes, per device | public prekeys only | no | prekey counts |
| Message history | yes, on devices that had it | no | no | no |
Transfer backup from getBackup(sessionIds) | yes | encrypted, if you route it that way | optional | size |
0.1.x; public APIs and persisted formats may change before 1.0.
Next
- Recovery, backup, and migration: the history question this page raises and does not answer
- Device lifecycle: registration, device IDs, and unlink
- Identity changes and safety numbers: what your peers see when you add a device
- Keys, identity, and sessions: why a device, not a user, is the unit
Offline send, receive, and reconciliation
How the SDK handles out-of-order and skipped messages, what the subscription APIs cover, and which parts of offline behaviour your application still owns.
Groups
The two group APIs in the SDK — sender-key messaging and encrypted GroupsV2 state — what each one solves, and why membership removal must rotate.