Skip to main content
WhatsApp is rolling out passkey-gated device linking: for some accounts the server refuses the normal QR / pairing-code flow and demands a WebAuthn passkey assertion before a companion can link. The official clients call this flow Shortcake (web side) and CRSCCompanion Registration over Side Channel (phone side). This page documents that flow as reverse-engineered from the WhatsApp Web bundle and the decompiled Android app, then cross-checked against a live, packet-correlated capture of a real linking. It is here for the curious and for contributors who need to understand why a passkey-gated account behaves differently.
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.
There is no headless bypass. The recurring conclusion — established empirically, not just inferred — is that on a passkey-gated account, linking a companion requires an assertion from the account owner’s own authenticator. This page explains why the wall holds, so you understand the boundary rather than waste time looking for a hole that isn’t there.

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.
Creating a passkey on the web is the generic Meta CAA/Bloks flow, not a WhatsApp-web-specific path, and is out of scope here.

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; from defaults to the s.whatsapp.net domain JID; t and id are required.
  • <passkey_request_options> is optional inline content (1–4096 bytes) — the WebAuthn request options as JSON, with challenge and each allowCredentials[].id in base64url. If absent, the companion fetches them with an IQ.
  • The web replies with a transport ack, then runs the prologue.
Empirically the server issues this as a continuation of an otherwise-normal pairing: on a gated account it answers the primary’s 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_helloprimary_hellocompanion_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 inline passkey_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_identity the companion reveals its nonce (via a companion_nonce IQ), 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 own PrimaryEphemeralIdentity, 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 runs navigator.credentials.get and 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 server s.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_proofnot 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 — the challenge inside clientDataJSON equals the challenge from the request options:
The CRSC protobufs (field numbers shown; values redacted):
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 mobile pair-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):
The <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_type is PASSKEY or CAPTCHA. A PASSKEY challenge carries a challenge_base64; a CAPTCHA one carries a site_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.
On the wire, MEX is GraphQL-over-IQ. The response goes out as a generic MEX envelope:
Only the decoded fields above and the response operation name were reverse-engineered. The exact operation ids, the full variable schema, and the server → client challenge push envelope were not captured — so, unlike the CRSC/ADV stanzas, the integrity-challenge MEX has no fully reversed wire form here (see Open questions).
The integrity checkpoint asserts an existing passkey (or a captcha). It does not create a passkey and does not route into the QR or Shortcake flow. The outcome is binary: pass and keep the session, or log out.

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=true to the server, and resumes the same QR pairing. On error it reports created=false and also resumes — the server decides.
The takeaway: creating a passkey ≠ linking by passkey. Creation is a gate that reports a signal to the server, after which the ordinary QR registration continues.

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 29205 in 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, no primary_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 at pair-device with <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.
The reason a headless client cannot manufacture the assertion is by design: WebAuthn requires proximity (hybrid/caBLE needs BLE), synced passkeys need the credential present on the machine calling 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.
For the protocol layers this flow sits on top of — Noise, stanzas, Signal, ADV — see The WhatsApp protocol and Architecture in depth.
Last modified on July 2, 2026