zapo is an independent, from-scratch implementation of the WhatsApp protocol and is not affiliated with, endorsed by, or connected to WhatsApp or Meta. Everything here is interoperability research derived from observing publicly shipped clients on the wire. All real identifiers, keys, signatures, and account data from the original capture have been redacted and replaced with placeholders.Overview of the passkey flows
Passkeys touch several distinct surfaces. The one that matters for a companion library is linking; the rest are listed for completeness.
The asymmetry is the key mental model:
- The phone (primary) owns the passkey — it creates, manages, and uses it (login, backup, linking), through the Android Credential Manager with the WebAuthn PRF extension.
- The web (companion) only uses a passkey — for linking and for the integrity checkpoint.
Shortcake: linking with a passkey
The trigger
The passkey prologue is server-driven. It is not a button, and not a client-side A/B flag — the companion cannot initiate it. The server pushes a notification to the web side:type="passkey_prologue_request"is the literal discriminator;fromdefaults to thes.whatsapp.netdomain JID;tandidare required.<passkey_request_options>is optional inline content (1–4096 bytes) — the WebAuthn request options as JSON, withchallengeand eachallowCredentials[].idin base64url. If absent, the companion fetches them with an IQ.- The web replies with a transport ack, then runs the prologue.
pair-device with a retry-with-method listing shortcake-with-passkeys (and, when it wants no fallback, omits qr-code). The passkey does not replace the QR step in the UI — it is bolted on after it.
The prologue rides either linking carrier. It can follow the QR
pair-device, or arrive right after a pairing-code link — the companion_hello → primary_hello → companion_finish handshake (handled on the web through the link_code_companion_reg notification). In captured logs the server sent passkey_prologue_request roughly 3 seconds after companion_finish.The prologue request stanza
The inlinepasskey_request_options decode to a standard WebAuthn get() request:
allowCredentials: [] means a discoverable credential — the account already has a passkey, so no credential id needs to be named. rpId is whatsapp.com.
The companion prologue
On receiving the request, the web side runs a fixed sequence:1
Get request options
From the inline content, or via an IQ if the server did not embed them.
2
Perform the WebAuthn assertion
Decode
challenge / allowCredentials[].id from base64url and call navigator.credentials.get({ publicKey }). This is the passkey prompt — user verification happens on the owner’s device. Re-encode clientDataJSON / authenticatorData / signature / userHandle back to base64url.3
Fetch a linking ref
An IQ
GetRef returns the ref string that scopes this linking session.4
Initialize the Shortcake session
Generate the ephemeral keypair, nonce, and commitment, and assemble the
prologue_payload (see cryptography).5
Optionally compute a handoff proof
If a pairing handoff key is available (derived from the ADV secret), compute the proof — it lets the phone skip its confirmation UX.
6
Send the prologue
An IQ carries
credential_id, webauthn_assertion, prologue_payload, and (optionally) pairing_handoff_proof. The web then waits for the primary’s identity.The linking state machine
The companion advances through a small state machine, guarded by a 120-second timeout:- On
primary_ephemeral_identitythe companion reveals its nonce (via acompanion_nonceIQ), derives the verification code, and — unless a valid handoff proof let both sides skip it — shows the code for matching. - Confirmation encrypts the pairing request (AES-GCM) and sends it as
encrypted_pairing_request.
The cryptography
The whole handshake is a commit-reveal ECDH carried entirely in stanzas (because, unlike QR, there is no out-of-band channel carrying the keys):
The commitment binds the companion’s nonce before the primary answers, so the primary cannot grind a chosen verification code.
The primary (phone) side
On the phone, CRSC is the mirror image. It receives the prologue (relayed by the server), generates its ownPrimaryEphemeralIdentity, and derives the same code. Two behaviours are worth calling out because they are frequently misread:
- Auto-confirm via handoff proof. If the companion included a valid
pairing_handoff_proof— an HMAC over the prologue keyed by a handoff key derived from the ADV secret, gated on a phone-side A/B flag, the app being in the foreground, and an unexpired stashed key — the phone skips its own confirm-device and code-matching screens. - The handoff proof is a primary-side shortcut, not a companion bypass. It removes friction for the phone’s owner. It does not remove the companion’s obligation to produce a real
webauthn_assertion. In the same linking, the web still runsnavigator.credentials.getand sends the assertion; the two artifacts serve different purposes.
The server validates the
webauthn_assertion and does not relay it to the phone. The primary receives only prologue_payload + pairing_handoff_proof — never the assertion. It trusts the handoff proof; the server is the party that checked the passkey.Passkey vs QR: the stanza sequence
Both flows converge on the same ADV registration of the companion, but the choreography differs sharply. Passkey (from the web/companion’s point of view — ◄ received / ► sent):
QR (for contrast):
The difference: QR is two IQs each way, the companion only responds, and the key exchange rides the QR out of band. Passkey has the companion initiate the IQs and carries the entire ECDH handshake in stanzas — because there is no QR to carry the keys.
Correlated wire capture
To move from “reconstructed from bundles” to “confirmed”, the flow was captured on both ends of one real linking at the same time — live instrumentation on the phone (primary) and a console capture on WhatsApp Web (companion) — then correlated byte-for-byte. All values below are redacted.Unified timeline
The CRSC relay
The servers.whatsapp.net is a relay. Each side sends an <iq to="s.whatsapp.net">; the other receives it as a <notification from="s.whatsapp.net"> (crsc_continuation / passkey_prologue). The proof that it is genuinely one session: the five CRSC payloads are byte-identical on both ends (the ephemeral X25519 key is random per session, so this is not coincidence).
Routing works because the primary’s
primary_ephemeral_identity IQ carries companion_ref equal to the ref inside the prologue_payload, so the server can deliver it to the correct web session.
The
passkey_prologue the phone receives contains only prologue_payload + pairing_handoff_proof — not the webauthn_assertion. The server validates the assertion and does not forward it. The assertion never reaches the phone.Decoded payloads
The WebAuthn assertion is a genuine ceremony — thechallenge inside clientDataJSON equals the challenge from the request options:
companion_nonce is the 32-byte reveal of the commitment. encrypted_pairing_request is AES-GCM over the pairing request (companion public key, identity key, ADV secret) under the ECDH-derived key — not decodable without that key.
Node-level detail
For completeness, the two ADV registration stanzas that bookend the CRSC exchange — the mobilepair-device (the registration attempt that draws the retry-with-method) and the pair-success the companion receives once CRSC completes — with their child nodes (values redacted):
<challenges><supported> axis (friction / passkey-create) is orthogonal to retry-with-method: the former declares which creation challenges the primary supports, the latter is the server’s chosen linking method. Advertising or omitting passkey-create does not change the method decision.
The integrity checkpoint
A second, unrelated passkey surface on the web is the anti-abuse integrity checkpoint — the sibling of the captcha challenge. It is not random: it is a server-side, risk-based decision.- The server pushes a MEX (GraphQL-over-WA) notification whose
challenge_typeisPASSKEYorCAPTCHA. APASSKEYchallenge carries achallenge_base64; aCAPTCHAone carries asite_key+challenge_url. The chosen challenge is persisted so it survives a page reload. - For
PASSKEY, the web opens a non-dismissible modal offering: complete the challenge, or log out. - Completing it runs
credentials.get({ rpId: "whatsapp.com", userVerification: "preferred", extensions: { prf: { eval: { first: "whatsapp-challenge" } } } })and submits the assertion (plus its PRF output) via a MEX mutation.
Passkey-create friction
A third surface — easily confused with linking — is the passkey-create bottom sheet that appears during a normal QR / pairing-code link. It is an anti-abuse gate in the middle of QR pairing, not a link-by-passkey flow.- “Don’t link” cancels the pairing outright (it does not fall back to QR).
- “Create” runs the passkey-creation flow, reports
created=trueto the server, and resumes the same QR pairing. On error it reportscreated=falseand also resumes — the server decides.
Who decides: passkey or QR
- The user does not choose the method — not on the web, not in the phone’s linked-devices screen. On the phone the user only approves (biometric + code confirm) or cancels.
- The server decides, based on an A/B allocation (a per-account flag, id
29205in the server-pushed config overlay), whether the account has a passkey, and a risk/integrity signal (the captcha family). - Hard client-side gates still apply: WebAuthn must be available, PRF must be supported, and (for the handoff shortcut) the phone must be in the foreground.
- The exact server risk criterion is not in the client bundles — it is server-side by design.
Why the passkey is central: PRF
WhatsApp passkeys use the WebAuthn PRF extension to derive a stable secret from the credential (extensions.prf with an eval input like "whatsapp-challenge"). That PRF-derived secret backs login, encrypted backup, and related surfaces — which is why the passkey is load-bearing rather than a mere second factor.
Can the passkey be bypassed
Every client-side lever was tested on live accounts. All of them fail; the wall is enforced server-side, per account.The client-side levers
The capability the phone advertises and the phone-side flag are both reactive — they only decide whether the phone obeys a server
retry-with-method that has already been sent. Neither is a trigger. The decision is 100% the account’s server-side bucket.
The server verifies the assertion
The last uncertainty — does the server actually verify the assertion signature, or accept any well-formed structure plus a valid handoff proof? — was tested by relaying a forged assertion: the real discoverable credential id, but signed with a different P-256 key over the fresh server challenge, alongside a legitimate handoff proof. The result: the server ack’d the transport IQ and then went silent — it did not relay the prologue to the phone, noprimary_ephemeral_identity came back, and both ends stalled until timeout. The server found the credential, checked the signature against the registered public key, failed, and rejected silently (no error IQ — a likely anti-enumeration design). A valid handoff proof did not substitute for a valid assertion.
Confirmed empirically: the server verifies the WebAuthn assertion against the account’s registered public key, with a fresh challenge (no replay). A forged assertion is rejected. The passkey wall is real and enforced end-to-end.
Platform and version gates
On a gated account the server also gates on the companion’s platform and build:- Platform. Any web companion (Chrome, Firefox, Safari, Electron desktop, UWP — all
platform_type = 2) is routed into the passkey. An android companion (platform_type = 10) is rejected up front with<error code="463" text="account_reachout_restricted"/>— the Shortcake ceremony is web-only, so the server refuses rather than route to a flow the phone could never complete. Switching to android does not dodge the passkey; it just trades one wall for another. - Version. No client version avoids it. Too old is rejected at the Noise handshake (
<failure reason='405'/>, client_too_old); a fake-but-recent build fails atpair-devicewith<error code="400" text="bad-request"/>(the server validates the build hash against real builds); only the genuine current build passes both — into the passkey.
Practical takeaway
For any headless or companion client, the boundary is concrete:- A passkey-gated account (server bucket
29205=true) offers Shortcake as the only completion path — no QR fallback. Linking such an account requires a real assertion from the owner’s own authenticator, obtained on the owner’s device/browser and relayed into the flow. That is using the real passkey, not bypassing it. - A non-gated account links normally with QR / pairing code; the passkey machinery is never engaged.
get(), and the private key is non-exportable. None of these yield to a remote, credential-less caller.
Open questions
The parts that remain unknown are, by design, server-side:- The exact risk criterion that puts an account in the passkey bucket (beyond “has a passkey” + the A/B allocation).
- What, beyond that, makes the server emit
passkey_prologue_request. - The full schema of the MEX integrity-challenge mutation and its receiving side.
