Skip to main content

Overview

WhatsApp-Rust supports two 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
Both methods use the Noise Protocol for secure key exchange 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
  • 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)

Implementation

QR code events

Event: Event::PairingQrCode
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.

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 the OS reported in DeviceProps:
  • 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).
  • Empty OS substitutes Linux.
The server only validates that the display string is 1..=100 bytes — there is no browser whitelist.

Pair code events

Event: Event::PairingCode
Generated in: src/pair_code.rs:215-219

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:229-376

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 (whatsmeow’s convention).

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.

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

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):
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