Skip to main content

Overview

WhatsApp-Rust supports three authentication methods for linking companion devices:
  1. QR Code Pairing - Scan a QR code with your phone
  2. Pair Code (Phone Number Linking) - Enter an 8-character code on your phone
  3. Passkey Linking (SHORTCAKE_PASSKEY) - Gate the link behind a WebAuthn passkey already registered to the account
All three methods use the Noise Protocol for secure key exchange (passkey linking additionally requires a WebAuthn assertion) and can run concurrently - whichever completes first wins.

Authentication Flow

QR code pairing

How it works

Location: src/pair.rs, wacore/src/pair.rs
  1. Server sends pairing refs: After connection, server sends pair-device with multiple refs
  2. Generate QR codes: Each ref becomes a QR code containing device keys
  3. QR rotation: First code valid for 60s, subsequent codes for 20s each
  4. Phone scans: User scans QR with WhatsApp > Linked Devices
  5. Crypto handshake: Noise-based key exchange establishes trust
  6. Completion: Server sends pair-success, device signs identity

QR code contents

QR Format: ref,noise_pub,identity_pub,adv_secret,client_type
  • ref: Pairing reference from server
  • noise_pub: Static Noise public key (32 bytes, base64)
  • identity_pub: Signal identity public key (32 bytes, base64)
  • adv_secret: Advertisement secret key (32 bytes, base64)
  • client_type: Single-byte CompanionWebClientType wire id (e.g. 1 for Chrome, 9 for OtherWebClient)
Current WhatsApp Web emits the 5-field form (with the trailing client_type). The parser (PairUtils::parse_qr_code) still accepts the legacy 4-field string for backwards compatibility, but make_qr_data always produces 5 fields.

Implementation

QR code events

Event: Event::PairingQrCode(PairingQrCode)
Breaking change: PairingQrCode moved from inline fields on Event::PairingQrCode { code, timeout } to a dedicated #[non_exhaustive] struct sealed with a bon builder — Event::PairingQrCode(PairingQrCode). Update match/if let patterns to destructure through the newtype (with a .. rest), and construct via PairingQrCode::builder().code(code).timeout(timeout).build() instead of a struct literal.
Generated in: src/pair.rs:63-116 The rotation loop includes a safety guard that checks is_logged_in() before emitting each QR code. This prevents stale QR events from firing after pairing completes — important for single-threaded runtimes, fast auto-pair scenarios, and mock servers where the spawned task may not be polled until after pairing succeeds.
The rotation uses futures::future::select with an async_channel stop signal rather than Tokio-specific primitives. This keeps the QR rotation compatible with any async runtime, since the Client uses the pluggable Runtime trait for sleep and spawn operations.
By default the Event::PairingQrCode code is the raw comma-separated string above. It is meant to be scanned from inside WhatsApp (Linked Devices → Link a Device) — it is not a URL and tapping it does nothing. WhatsApp Web (WAWebLinkDeviceQrcode) also supports a deep-link shape for iOS native-camera linking: prefixing the same payload with a wa.me URL turns the QR into a link. On iOS, scanning it with the native Camera app (not the in-app scanner) opens WhatsApp straight to the Linked Devices screen and hands off the pairing payload via the URL fragment (#...). The prefix is exported as a constant:
make_qr_data and Event::PairingQrCode never add this prefix — the emitted string is always the raw 5-field payload. If you want the deep-link behavior you must prepend the prefix yourself before rendering the QR. PairUtils::parse_qr_code transparently strips the prefix, so the raw and deep-link forms are interchangeable on the scanning side.
Render the raw code instead of deep_link if you want the classic in-app scanner flow; both produce a valid, scannable code.

Pair code (phone number linking)

How it works

Location: src/pair_code.rs, wacore/src/pair_code.rs
  1. Generate code: 8-character Crockford Base32 code
  2. Stage 1 - Hello: Send phone number + encrypted ephemeral key
  3. Server response: Returns pairing reference
  4. User enters code: On phone: WhatsApp > Linked Devices > Link with phone number
  5. Stage 2 - Finish: Phone confirms, companion sends key bundle
  6. Completion: Server sends pair-success

Pair code format

Alphabet: Crockford Base32 (excludes 0, I, O, U)
Length: Exactly 8 characters Example: ABCD1234, MYCODE12

Implementation

Random Code

Custom Code

Pair code options

CompanionWebClientType

CompanionWebClientType is the wire-level enum emitted in the <companion_platform_id> child of the pair-code IQ. Each variant has a fixed single-byte ASCII identifier returned by wire_byte:
The proto’s UNKNOWN (wire '0') is intentionally absent — WA Web never emits it from a real browser and the server rejects it. The default is OtherWebClient ('9'). The server accepts 23 single-byte ids (0..9 and a..m); only the 12 with a confirmed platform meaning are exposed.

Mapping from PlatformType

companion_web_client_type_for_platform maps each wa::device_props::PlatformType to a wire variant. Web platforms map to their browser variant (Chrome, Firefox, Edge, etc.). Desktop maps to Electron. The Android PlatformType variants (AndroidPhone, AndroidTablet, AndroidAmbiguous) map to Chrome — that’s what real WA Web on Chrome-Android emits and what the server accepts without attestation. To request the Android letter codes ('d'/'e'/'f') explicitly, set PairCodeOptions::platform_id. iOS, AR/VR, Wear OS, and the proto’s UNKNOWN collapse to OtherWebClient.

companion_platform_display

The display string sent in <companion_platform_display> is built from the resolved wire variant and a canonicalized OS derived from DeviceProps::os:
  • Web variants emit <Browser> (<OS>), e.g. Chrome (Linux), Firefox (Windows). Non-browser web variants (Electron, UWP, OtherWebClient) and Android-mapped-to-Chrome fall back to Chrome (<OS>), mirroring WA Web’s reported renderer name.
  • Explicit AndroidPhone/AndroidTablet/AndroidAmbiguous overrides emit Android (<OS>), e.g. Android (Android).
Unlike QR pairing — which never sends this field and so tolerates an arbitrary branding string in DeviceProps::os — the pair-code companion_hello server rejects a non-OS companion_platform_display with bad-request. The OS component is therefore canonicalized through wacore::companion_reg::CompanionOs into a small, server-safe set instead of using DeviceProps::os verbatim.
CompanionOs::from_hint classifies a free-form OS hint (case-insensitive, with whole-word guards so branding like "KaiOS"/"March"/"across" doesn’t false-match a substring) into one of: An OS that doesn’t classify (empty, or a branding string such as "Veloz") coerces to Linux — the same fallback QR pairing already used for an empty OS, now also covering non-OS branding strings. The client logs a one-time warn! when this coercion actually changes a non-empty os, so a consumer sees why their custom branding didn’t ride through. Escape hatch: set PairCodeOptions::display_os to send an OS verbatim, bypassing canonicalization entirely — useful to keep a real, server-accepted name the canonical set collapses (e.g. "Ubuntu"Linux). This is at the caller’s risk: a non-OS string here is rejected with bad-request. An all-whitespace override is ignored and falls back to the safe coercion. The server also validates that the display string is 1..=100 bytes.

Pair code events

Event: Event::PairingCode(PairingCode)
Generated in: src/pair_code.rs The validity clock (code_generation_ts) is stamped before the stage-1 companion_hello request is sent, matching WA Web’s startAltLinkingFlow. Because of this, timeout on the dispatched event is the remaining window (code_validity() - elapsed), not always the full ~180 seconds — otherwise a UI countdown built from the event would outlast the server’s (and this crate’s own handle_primary_hello) actual expiry check by however long stage 1 took.
Breaking change: PairingCode and PairingCodeRefresh moved from inline enum-variant fields (Event::PairingCode { code, timeout }, Event::PairingCodeRefresh { force_manual }) to dedicated #[non_exhaustive] structs sealed with a bon builder — Event::PairingCode(PairingCode) / Event::PairingCodeRefresh(PairingCodeRefresh). Destructuring patterns need a .. rest; construction goes through PairingCode::builder()…build().

Pair code refresh events

Event: Event::PairingCodeRefresh(PairingCodeRefresh)
Fired when a link_code_companion_reg notification arrives with stage="refresh_code" (WA Web refreshAltLinkingCode / forceManualRefresh) and its link_code_pairing_ref matches the outstanding request. The typical reaction is to call Client::pair_with_code again with the same phone number — the previous code is no longer guaranteed valid. Register a handler with Bot::on_pair_code_refresh or match on the event directly via on_event.

Two-Stage Flow

Stage 1: Hello

Purpose: Register phone number and encrypted ephemeral key
Response: Pairing reference

Stage 2: Finish

Trigger: link_code_companion_reg notification from server Handling: src/pair_code.rs handle_pair_code_notification dispatches on the notification’s stage attribute, mirroring WA Web’s handleAltDeviceLinkingNotification. An unrecognized stage is ignored without touching the in-progress flow:
primary_hello — the user entered the code on their phone:
  1. Extract primary’s wrapped ephemeral pub (80 bytes)
  2. Extract primary’s identity pub (32 bytes)
  3. Validate the notification before touching any crypto (checked in this order, so a rejected notification never consumes a retry slot):
    • link_code_pairing_ref must match the ref cached from companion_hello (WA Web InvalidRefError)
    • the code must still be within its ~180s validity window (WA Web OldCodeError)
    • at most PairCodeUtils::max_primary_hello_attempts() (3, matching WA Web’s T) genuine attempts are processed per code (WA Web MaxPrimaryHelloError) — a rejected attempt does not count against this cap
  4. Decrypt primary’s ephemeral key (expensive PBKDF2, run in spawn_blocking)
  5. Prepare encrypted key bundle
  6. Send companion_finish IQ
The whole of stage 2 runs under the pair_code_state lock, held from validation through the socket send. The transport dispatches <notification> stanzas on concurrent detached tasks, so two primary_hello notifications for the same code could otherwise each derive a different random adv_secret and race SetAdvSecretKey (last-write-wins) — desyncing the persisted secret from the companion_finish the server acts on. Holding the lock across the full stage makes concurrent notifications for the same code process sequentially, matching WA Web’s single-threaded model. State stays WaitingForPhoneConfirmation after a successful companion_finish (rather than moving to Completed) so a genuine retry can still reuse it — only pair-success (see crate::pair) completes the flow. refresh_code — the server asks the companion to regenerate the code it is displaying. Dispatches Event::PairingCodeRefresh with force_manual taken from the notification’s force_manual_refresh attribute, but only when the notification’s ref matches the outstanding flow (WA Web’s getCurrentRef() guard) — otherwise it is ignored.

Passkey linking (SHORTCAKE_PASSKEY)

How it works

Location: wacore/src/shortcake.rs (pure crypto/protobuf core), src/passkey/mod.rs (the PasskeyAuthenticator seam), src/passkey/flow.rs (the client driver) This gate requires a WebAuthn passkey already registered to the WhatsApp account (e.g. in Google Password Manager or iCloud Keychain) — it is not a standalone pairing method you can bootstrap from scratch like QR or pair code. The server asks the companion to prove possession of that passkey before it will hand over the ADV secret.
  1. Server requests a WebAuthn assertion: a passkey_prologue_request notification carries (or points to, via IQ) the PublicKeyCredentialRequestOptions JSON.
  2. Companion obtains an assertion: delegated to a registered PasskeyAuthenticator — e.g. Android Credential Manager — since the passkey’s private key is non-extractable and never touches this crate.
  3. Ephemeral-identity commit/reveal: the companion generates a fresh X25519 keypair and nonce, commits to them (<passkey_prologue>), and the primary reveals its own ephemeral identity in return (crsc_continuation).
  4. Shared key + verification code: both nonces and public keys derive an AES-256-GCM key and an 8-character “XXXX-XXXX” verification code.
  5. Encrypted pairing request: the companion encrypts its static Noise/identity public keys plus a freshly rotated ADV secret under that key and sends <encrypted_pairing_request>.
  6. Completion: as with QR/pair-code, the server sends pair-success and linking finishes through the same PairSuccess/PairError path.
The rotated ADV secret is held only in memory until send_passkey_confirmation succeeds — it is committed to the device store (DeviceCommand::SetAdvSecretKey) only after the primary has it. An abandoned or failed attempt never leaves the device on a secret the primary never received.
On a re-link — the device already has a prior linked identity (account, phone number, or LID persisted from an earlier pairing) — the client derives an HMAC “handoff proof” from the stored adv_secret_key and includes it in <passkey_prologue>. If the server accepts it as proof of continuity, Event::PairPasskeyConfirmation.skip_handoff_ux is true and the link can complete without showing the user a code. A fresh link (no prior account/pn/lid) never derives a handoff proof, even though adv_secret_key itself is always present — it’s randomly generated at device creation, so it can’t by itself signal continuity with a real prior link. A brand-new link therefore always shows the verification code.

PasskeyAuthenticator trait

Location: src/passkey/mod.rs
Two helper functions parse/build the wire shapes so a host authenticator doesn’t have to:
If you don’t need a custom integration, CallbackAuthenticator wraps any async closure as a PasskeyAuthenticator:

Driving modes

  • Automatic: with set_passkey_authenticator called, the client drives the assertion step — it calls get_assertion when the server asks and sends the response for you. It also auto-confirms re-links whose skip_handoff_ux is true, since continuity is already proven. A fresh link does not auto-confirm even in this mode: it still emits Event::PairPasskeyConfirmation, and you must show the code to the user and call send_passkey_confirmation() yourself once they approve it — otherwise the link stalls before <encrypted_pairing_request> is ever sent.
  • Manual: with no authenticator registered, the host drives every step from the three Event::PairPasskey* events (see below) and calls send_passkey_response / send_passkey_confirmation itself.

Implementation

Passkey events

Linking completes through the ordinary PairSuccess/PairError events — there is no separate “passkey success” event.
PairPasskeyRequest, PairPasskeyConfirmation, and PairPasskeyError are #[non_exhaustive], sealed with a bon builder (e.g. PairPasskeyRequest::builder().request_options_json(json).build()). Field access by name (req.request_options_json) is unaffected; only an exhaustive struct-pattern destructure would need a .. rest.

Client methods

See Client API — Connection Management for full signatures and error variants.

Cryptography

Noise protocol handshake

whatsapp-rust supports three Noise patterns to mirror WhatsApp Web:
XX flow (cold start):
  1. Initiator → Responder: ephemeral pub
  2. Responder → Initiator: ephemeral pub, static pub, encrypted payload (cert chain)
  3. Initiator → Responder: encrypted static pub, encrypted payload
The verified server_cert_chain is persisted at the end of XX so the next connect can use IK. IK flow (resumed):
  1. Initiator → Responder: ephemeral pub, encrypted static, encrypted 0-RTT payload (built against the cached server static)
  2. Responder → Initiator: ephemeral pub, encrypted payload — handshake is complete after this single round trip.
If the server’s static no longer matches the cached value, step 2 returns an IkServerHelloOutcome::Fallback(...) and the client pivots to XXfallback in-place — without dropping the connection — finishing as if it had been XX from the start. After a single crypto-fatal IK failure the client clears the cached cert chain (DeviceCommand::ClearServerCertChain), increments a process-local failure counter, and forces XX on the next connect. See WebSocket & Noise Protocol — Noise Protocol Handshake for the full state machine.

ClientProfile

Location: wacore/src/client_profile.rs ClientProfile is the identity that gets baked into ClientPayload.UserAgent during the Noise handshake. It controls the platform, device, os_version, os_build_number, manufacturer fields, and whether web_info is attached to the payload. It is independent of DevicePropsdevice_props describes the companion entry on the phone, while ClientProfile describes the client identity to WhatsApp’s server during the handshake itself. The two can be set independently.
Since v0.6 the locale and phone_id come from the active ClientProfile instead of being hard-coded. The locale is split into two ISO fields — locale_language (ISO-639-1, e.g. "en") and locale_country (ISO-3166-1 alpha-2, e.g. "US") — both written to the matching UserAgent proto attributes. When phone_id is None the client builds a fresh UUID-v4 on every ClientPayload build; it is not persisted on Device, so if you need a stable WA Web–style WAWebClientPayload.phoneId you must supply it yourself (e.g. generate once at install time and pass it in via your own ClientProfile constructor). Login counter (ClientPayload.lc) lives on Device, not here — see the Login counter section below.passive_login mirrors WA Web’s ClientPayload.passive: false (the default) tells the server to deliver queued offline messages on connect, true keeps the connection passive until you pull explicitly.

Built-in profiles

The web() profile reproduces the legacy desktop-web payload (os_version and os_build_number are both "0.1.0"). Native profiles propagate the supplied os_version to both fields and drop web_info.

Setting a profile

Device.client_profile is #[serde(skip)], so it is never persisted. Set it on every fresh process before calling connect():
Internally this dispatches DeviceCommand::SetClientProfile(profile) through the persistence manager (see State Management).

Key Derivation

For QR Code:
For Pair Code:
Parameters:
  • Algorithm: AES-256-CBC
  • KDF: PBKDF2-HMAC-SHA256
  • Iterations: 2^16 (65,536)
  • Salt: 16 random bytes
  • IV: 16 random bytes

Signal protocol setup

After pairing:
  1. Server sends signed device identity
  2. Companion verifies signature
  3. Identity keys exchanged
  4. Pre-keys registered

Login counter (ClientPayload.lc)

Since v0.6 the client persists a login_counter on Device. Every successful connect increments it and the new value is sent as ClientPayload.lc during the Noise handshake. This mirrors WA Web’s anti-abuse signal — the server uses the counter to spot replayed or cloned ClientPayloads. The counter resets when you call logout() or wipe device state.

One-to-one LID migration state

Fixes #941: some accounts are not yet 1:1-LID-migrated on WhatsApp’s servers, and those accounts get every LID-addressed DM rejected with ack error="400". The client tracks this account-level state in a persisted Device.lid_migrated flag so it can keep DM wire addressing on PN until the account has actually migrated — see Signal Protocol — DM wire namespace vs. Signal session addressing. The flag is set from two sources, both mirroring WA Web’s WAIsAccountLidFieldMigrated pref:
  1. Pair-success <client-props>. The primary attaches an optional <client-props> child to pair-success carrying ClientPairingProps.isChatDbLidMigrated. PairUtils::extract_pairing_props() decodes it; a malformed or absent payload is treated the same as “not reported” and never fails the pairing itself.
  2. lid_migration_mapping_sync_message. The primary can later push a ProtocolMessage.lid_migration_mapping_sync_message to its own companions (mirroring WA Web’s setLidMigrationMappings) carrying newly-assigned PN↔LID pairs. The client learns the mappings and, once the lid_one_on_one_migration_enabled ab prop allows it, persists the account as migrated. This message is honored only from is_from_me sources — a peer-sent copy is dropped, since accepting it would let a peer poison the LID-PN cache and flip your own account’s wire addressing.
Once set, the flag never reverts for the same account (like the WA Web pref) — it can only be reset to false when a different account is paired onto the same store. Mutations go through DeviceCommand::SetLidMigrated(bool) (see State Management).

Concurrent Pairing

Both methods can run simultaneously:
State Management:
Cancellation:
The is_logged_in() safety guard in the rotation loop acts as a fallback — even if the cancellation channel hasn’t been stored yet (race condition on fast pairing), the rotation task will exit cleanly on its next iteration.

Success Events

PairSuccess

PairError

Breaking change: PairSuccess, PairError, and LoggedOut (below) are now #[non_exhaustive] and sealed with a bon builder. A struct literal from outside wacore/whatsapp-rust no longer compiles — construct via Type::builder()…build(), and add a .. rest to any destructuring pattern.

Error Handling

QR code errors

Pair code errors

pair_with_code returns whatsapp_rust::pair_code::PairError, which wraps the wacore-side validation/crypto errors (PairCodeError) and the IQ transport layer (IqError):
A PairError::RequestFailed carrying bad-request (400) is not necessarily a permanent/invalid-input failure — the server reuses the same error for rate-limiting (it throttles pair-code requests per phone number), and the two are indistinguishable in the response. Back off and retry rather than treating every 400 as fatal. By default, the library canonicalizes companion_platform_display’s OS (see companion_platform_display), so display-shaped rejections are generally ruled out unless you explicitly bypass canonicalization via PairCodeOptions::display_os. Any server backoff hint is preserved on the wrapped IqError::ServerError (see Error Types).
The previous catch-all CryptoError(String) and RequestFailed(String) variants have been split into typed variants that preserve their underlying source. Match on std::error::Error::source() (or downcast it) to inspect the inner CurveError, CryptoProviderError, or IqError.

Session Persistence

After successful pairing

State saved to storage:
  • Device JID (Phone Number)
  • LID (Long-term Identifier)
  • Identity keys
  • Noise keys
  • Registration ID
  • Push name
Next connection:

Logout

Best Practices

Phone number format

Event Handling

Concurrent Usage

Architecture

Understand the project structure

Events

Learn about all event types

Storage

Explore session persistence

Quick Start

Build your first bot