Hybrid Transport and Cross-Device Passkeys
Hybrid transport — the CTAP2 mechanism formerly called caBLE — lets a user sign in on a laptop using a passkey that lives on their phone, brokered by a QR code and a Bluetooth proximity check. It is why a passkey held only on an iPhone can authenticate a session in desktop Chrome. This page explains the handshake, the transports hint that surfaces it, the UX to present, and the proximity security model that keeps it phishing-resistant. It sits under platform vs roaming authenticator trade-offs.
Mechanism Reference
| Element | Role |
|---|---|
| QR code (on the target device) | Carries a one-time key-agreement handshake |
| Bluetooth Low Energy | Proximity proof — the phone must be physically near the target |
| Tunnel service | Relays encrypted CTAP2 messages between phone and target |
transports: ['hybrid'] |
The credential hint that a passkey may be used cross-device |
authenticatorAttachment: 'cross-platform' |
The phone answers a get() as a roaming authenticator |
The QR and BLE steps together prevent remote relay: an attacker who phishes the QR cannot complete the ceremony without also being in Bluetooth range, so the origin-binding guarantee is preserved even across devices.
Architecture and Data Flow
Root Cause Analysis (of cross-device failures)
1. No hybrid transport hint stored. If you saved a credential without transports, some clients will not surface the cross-device option, so a phone-only passkey appears unusable on desktop.
2. Bluetooth disabled or unsupported. Hybrid requires BLE on both devices; a desktop without Bluetooth cannot complete proximity, and the ceremony stalls or times out.
3. Treating hybrid as a fallback, not a first-class path. Hiding “use another device” behind an error confuses users who legitimately keep their passkey on a phone.
Step-by-Step Resolution
Step 1 — Persist transports at registration
const transports = credential.response.getTransports?.() ?? []; // e.g. ['hybrid','internal']
await saveCredential({ credentialId, transports, aaguid });
Step 2 — Echo transports in allowCredentials
allowCredentials: creds.map((c) => ({
id: c.credentialId,
type: 'public-key',
transports: c.transports, // include 'hybrid' so the client offers cross-device
})),
Step 3 — Present cross-device as a deliberate choice
Offer “Use a passkey on another device” in the modal picker (the browser renders the QR), not only after a local failure.
Step 4 — Feature-detect and message BLE
If a local ceremony fails and the platform lacks Bluetooth, message the user that cross-device needs Bluetooth on both devices rather than showing a generic error — see fixing NotAllowedError.
Verification and Testing
Cross-device flows are hard to fully emulate, but you can verify the plumbing:
// Assert transports are captured and echoed
expect(saved.transports).toContain('hybrid');
expect(options.allowCredentials[0].transports).toContain('hybrid');
End-to-end, test on real hardware: register a passkey on a phone, then sign in on a desktop and confirm the QR + Bluetooth flow completes. Confirm that a desktop with Bluetooth off surfaces a specific “enable Bluetooth” message, not a generic failure.
Pitfalls
1. Dropping the transports hint. Store and echo ['hybrid', …] or cross-device may not appear.
2. Hiding cross-device behind an error. Present it as a normal option for phone-resident passkeys.
3. Assuming relay is possible remotely. The BLE proximity check is intentional; do not attempt to bypass it — it is what preserves phishing resistance.
Related
- Platform vs Roaming Authenticator Trade-offs — where hybrid sits between platform and roaming
- Choosing authenticatorAttachment: Platform vs Cross-Platform — the
cross-platformvalue the phone answers - How WebAuthn Prevents Phishing Attacks — the origin binding the proximity check preserves