Identity changes and safety numbers
Generating safety numbers, handling identity-key change errors, and designing a verification experience that users can actually complete.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 12 min
Trust-on-first-use gets you a working session against a key you have never checked. It detects a later key change. It proves nothing about who was present at first contact. This page closes that gap, where the hard part is outside the cryptography.
Generating comparison data
The client method takes a user ID and returns the comparison object. It also returns an immutable token that confirms the exact displayed value:
const safetyNumber = await signal.verify(remoteUserId);
showVerificationDialog(safetyNumber);
await signal.confirmSafetyNumber(safetyNumber.confirmation);The two steps are separate on purpose. A generated safety number never promotes
trust. Only confirmSafetyNumber(confirmation) records a decision. It takes
evidence for the value that the user saw. This token prevents accidental
confirmation if the key rotates between render and tap.
For the full set of representations, including the scannable form, use the standalone module:
import { generateCompositeSafetyNumber } from '@open-e2ee/signal-protocol-sdk/safety';
const safetyNumber = generateCompositeSafetyNumber(
localCompositeIdentity,
remoteCompositeIdentity,
localUserId,
remoteUserId,
);
console.log(safetyNumber.numeric); // 60 digits, for reading aloud
console.log(safetyNumber.emojis); // for comparing on a screen
const comparison = safetyNumber.scannable.compare(scannedQrBytes);
if (comparison === 'match') {
// record the user's decision
}Trust starts at UNVERIFIED_TOFU. Authenticated safety-number comparison covers
both peers' complete composite tuples. It promotes only the exact current tuple to
VERIFIED. "Exact current tuple" defines the scope. Trust applies to one specific
set of keys at one moment, not permanently to a contact.
The SDK creates comparison data; the application owns QR rendering, scanning, the verification user experience, and storage of the user's trust decision.
When an identity changes
The contact reinstalls, their new device generates a new identity, and the relay
starts serving a tuple you never pinned. Two errors surface from that, on two
different calls, and neither of them is an event the SDK hands you. There is no
identity-change hook: registering every entry in SignalProtocolClientHooks and
then changing an identity underneath a live session fires none of them.
The session path fails closed, but not at your call site
UntrustedIdentityError (.code UNTRUSTED_IDENTITY) is the identity error the
SDK raises. It is constructed in five places: two on the
sending side — building a session against a fetched prekey bundle, and
encrypting on an established one — and three inside decrypt. It carries the
address it refused and the tuple it refused for it:
import { isUntrustedIdentityError } from '@open-e2ee/signal-protocol-sdk/types';
try {
await signal.send(remoteUserId, body);
} catch (error) {
if (isUntrustedIdentityError(error)) {
// .untrustedAddress — who. .identity — the tuple the SDK would not use.
await queueForUserReview(error.untrustedAddress, error.identity);
return;
}
throw error;
}Keep that handler, and know what it is for. Both sending-side throws sit behind
isTrustedIdentity(…, SENDING, …), and a contact reinstalling does not on its
own make that answer false. On 0.1.0-alpha.12 the send path was probed in four
configurations after the far device had rebuilt — an established ratchet, a
deleted session forcing the prekey-bundle path, a VERIFIED trust state promoted
through confirmSafetyNumber, and VERIFIED with the session deleted — and
every one resolved with nothing thrown. So the handler is defence in depth for
the paths that do reach a caller, not the branch a reinstall takes. Do not build
the reinstall experience on it; build it on the section below.
verify() refuses before you accept
Once the relay is serving an identity that is not the one you pinned, verify()
does not return a new safety number to show. It throws EncryptionError with
.code IDENTITY_MISMATCH:
import {
EncryptionErrorCode,
isEncryptionError,
} from '@open-e2ee/signal-protocol-sdk/types';
try {
showVerificationDialog(await signal.verify(remoteUserId));
} catch (error) {
if (isEncryptionError(error) && error.code === EncryptionErrorCode.IDENTITY_MISMATCH) {
// the relay serves a tuple you have not pinned; accept it or refuse it
return promptForIdentityChange(remoteUserId);
}
throw error;
}The pinned tuple is the trust object, and the relay is never allowed to select which tuple the user sees. That rule is what produces this refusal, and it constrains the screen you can build: there is no "here is the new safety number, compare it" dialog available before the change has been accepted. Accepting comes first, comparing comes second, and a design that assumes the other order cannot be implemented on this SDK.
confirmSafetyNumber raises the same code in three more places — when the
confirmation does not match the pinned identity, when it does not match the value
that was displayed, and when the relay's identity moved between display and tap.
Each is the same guard: a decision is recorded only against the exact tuple the
user actually saw.
Accepting the change
Accepting is explicit, and it takes the tuple:
const record = await signal.acceptIdentityRotation(remoteUserId, error.identity);The tuple is the one on the error, if you caught it. If you did not, it is the
one the relay is serving — read it from the relay you configured, with
relay.getIdentityKey(remoteUserId). Supplying the tuple that is already trusted
throws.
Acceptance resets the sessions bound to the old tuple and returns the contact's
identity record. It does not mark the contact VERIFIED. Accepting a rotation
and verifying it are different acts. If one tap combines them, a "verified" badge
loses its meaning.
The part nobody wants to hear
Users cannot do this. The evidence is not ambiguous.
In one study, 21 of 28 computer science students could not verify a public key. In another, which explained the risks first, only 13% completed the ceremony. These people are not the general population. They started with a reason to care, and most of them still did not finish.
Keybase, whose users were unusually motivated, put it plainly:
"Checking is infeasible, since it happens way too often. Checking sucks."
They reframed trust-on-first-use as TADA, Trust After Device Additions. In a multi-device product, the decision does not occur only at first contact. Users repeat it every time anyone upgrades a phone, reinstalls an app, or links a tablet. On a fleet of any size, that is a steady drip of identity changes with a base rate that is almost entirely benign.
Where the industry actually went
The mature answer is not a better dialog. It is to make key changes auditable rather than to ask each user to adjudicate them.
Apple's Contact Key Verification and WhatsApp's Automatic Device Verification both move in this direction. The system checks continuity in the background. A person participates only after a failed check or when the stakes justify a real ceremony. The human decision becomes an exception path rather than a routine one.
This SDK gives you the comparison data. It does not give you a transparency log or an automated continuity check, and we do not claim to. What you can build with what is here:
- Show the safety number only where it earns attention: a deliberate "verify this contact" flow, not an unsolicited banner.
- Use
scannable.compare(scannedQrBytes)for in-person verification. A QR scan succeeds where a 60-digit read-aloud does not. - Persist every
UntrustedIdentityErrorwith.untrustedAddressand.identityto an auditable record that your application owns — from the handler above and from wherever your logger sink writes, since on a reinstall the refusal reports there rather than to a caller. The tuple you held before it is the contact identity record you already have, so the record has both sides. A security team can use it to answer questions later. Do not make the user act as a verification oracle in the moment. - Reserve the interrupt for cases your product can justify. Examples include a first message, a high-value action, or an account that your own signals flagged.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
localCompositeIdentity private half | yes | no | no | no |
localCompositeIdentity public half | yes | yes | no | yes |
remoteCompositeIdentity | yes, cached | fetched from relay | no | yes |
safetyNumber.numeric | yes | no | no | no |
safetyNumber.emojis | yes | no | no | no |
safetyNumber.scannable bytes | yes | no | no | no — it crosses out of band, by QR |
safetyNumber.confirmation | yes | no | no | no |
Trust state (UNVERIFIED_TOFU / VERIFIED) | yes | no | no | no |
UntrustedIdentityError.identity | yes | already public | no | yes |
| Your record of the user's verification decision | yes, app-owned | no | no | no |
Trust state is device-local. If a user verifies a contact on their phone, their
tablet does not know. Call syncVerificationStateToLinkedDevices(...) to send that
state. The SDK does not run this call in the background. See
Multi-device.
0.1.x; public APIs and persisted formats may change before 1.0.
Next
- Multi-device: the main reason your users' safety numbers change
- Error handling: the full identity and session error taxonomy
- Threat model: decide whether to ask for verification
- Offline, recovery, and identity change: a worked flow end to end
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.
Recovery, backup, and migration
Three named threat-model profiles for the "lost device" question, the SDK operations that support each, and why there is no correct default.