Cross-device passkeys are still a compatibility minefield
While building cross-device passkey support for Capy, we ran into several compatibility issues that could trip up other teams working with passkeys, especially teams using them for encryption. This blog post summarizes and shares the issues we found.
- Passkey requests behave inconsistently across devices and setups. A request can succeed in one setup but produce an “Unable to use passkey” dialog or return without confirming user verification in another.
- User verification can succeed while the same passkey returns different encryption keys across devices. In our tests, we use the same 1Password passkey directly on an iPhone and by scanning desktop Chrome’s QR code with that phone. Both requests confirm user verification, but they return different encryption keys even though we supply the same input value to the passkey in each request. The key from one request cannot decrypt the message encrypted with the other.
- Several combinations work as expected. In our Apple Passwords tests, we can encrypt on the phone and decrypt on desktop, or vice versa. This works through both a QR request to the phone and direct access to the passkey from the desktop password manager. The 1Password desktop extension also works flawlessly in both directions. When we use Chrome Password Manager on both devices, encryption works correctly, but user verification behaves differently across devices.
Why we care about cross-device passkeys
We’re building Capy to make your API keys portable and securely usable in any runtime, whether it’s a sandbox, a VM, your new computer, or your local machine.
We do this with Transports. A Transport delivers the keys your runtimes need to decrypt your API keys while preserving Capy’s Zero-Knowledge architecture. Each Transport is secured using your own key material. This key material is either a plaintext passphrase, or a Pawprint, which is our mechanism of using passkeys to generate key material.
When using Pawprints on a new runtime, you simply scan a QR code with your phone, approve your passkey, and unlock your Transport. You can unlock Transports in either your phone’s browser or a desktop browser, giving you flexibility in how you use Capy to move your secrets around.
This is achieved using WebAuthn, the browser API for passkeys. WebAuthn offers a pseudorandom function (PRF) feature. Our application supplies an input value with the passkey request, and PRF produces a secret output for that passkey and input. Pawprint uses this output as key material. Using the same passkey and input should reproduce the same output, even when we make the request from another device. Apple documents that expectation, and Bitwarden uses PRF to support vault decryption. Apple PRF documentation, Bitwarden passkey unlocking.
Cross-device passkey support is important for Capy because a Transport needs to remain accessible wherever you use Pawprint. The same passkey must therefore produce compatible key material across devices and supported access methods, even when different software handles the request. Otherwise, a Transport that unlocks on your phone may stay locked when you try to use it from a desktop browser.
Testing passkeys across devices
We first ran into a cross-device problem when we tried to unlock the same Transport on an iPhone and in desktop Chrome. We unlocked it on the iPhone with a 1Password passkey, but could not unlock it in Chrome after scanning the QR code with the same phone and using the same passkey.
To diagnose this issue, we created a test bench to investigate how passkeys behave across different devices, browsers, password managers, and access methods, without involving Capy’s accounts, database, or key-handling code.
Open Passkey LabCompare the key material a passkey returns on different devices.Each test started with a new passkey that we used only for testing. After creating it, we made fresh passkey requests through navigator.credentials.get for every comparison. One device encrypted a public message with the PRF output. The other asked the passkey for its own output and tried to decrypt that message.
We used AES-256-GCM to encrypt the test message, using the PRF output itself as the encryption key. We did not compare the passkey’s creation result with a later use. None of these tests exposed real Transport keys.
We tested two ways to use the same passkey on desktop Chrome:
- QR access: Chrome displayed a QR code. We scanned it with the phone and used the passkey there.
- Direct desktop access: Chrome used the password manager on the desktop, through an extension or a synced passkey. The request did not go back to the phone.
Issue 1: Inconsistent behavior when retrieving passkeys
The userVerification option on a WebAuthn passkey request controls whether the browser must verify the user. In Capy userVerification was originally set to preferred by default. preferred asks the browser to verify the user but allows a response without verification. With required, the browser must verify the user or fail the request. The response’s user-verification flag records whether verification succeeded. WebAuthn verification settings.
In our Chrome Password Manager tests, desktop responses with preferred did not confirm verification. We observed this both when the desktop tried to decrypt a message from the phone and when it tried to encrypt one for the phone.
The table shows our Chrome Password Manager attempts in both directions. The desktop preferred attempts stopped at user verification, before encryption or decryption. A dash means we never reached the decryption request.
| Direction | Encryption setting | Decryption setting | Encrypt | Decrypt |
|---|---|---|---|---|
| iPhone Chrome → Desktop Chrome | preferred | preferred | Pass | Fail |
| iPhone Chrome → Desktop Chrome | preferred | required | Pass | Pass |
| Desktop Chrome → iPhone Chrome | preferred | — | Fail | Not reached |
| Desktop Chrome → iPhone Chrome | required | preferred | Pass | Pass |
For the successful phone-to-desktop run, we also moved the test to Passkey Lab and created a new passkey. Because the website and passkey changed along with the desktop setting, we cannot attribute that result to required alone.
Other tests confirmed verification with preferred on both sides: Apple Passwords over QR and direct desktop access in both directions, 1Password over QR in both directions, and phone-to-desktop access through the 1Password extension. The 1Password QR runs still returned mismatched encryption keys.
In the reverse 1Password extension test, we used required when the desktop requested key material to encrypt the message and preferred when the phone requested it to decrypt the message. Both responses confirmed verification. We have no record of a failed preferred attempt for that run, so we cannot say that the desktop needed required.
These settings applied to requests for key material from an existing passkey. We used required when creating the passkeys themselves.
We saw a separate failure in Safari’s passkey-selection prompt. After saving a test passkey in 1Password, we clicked “Encrypt both cases and prepare test,” then “Use Passkey” in the initial prompt. Safari displayed “Unable to use passkey.” We then chose “Try other option → 1Password” and completed the request with the same preferred setting.
We don’t know which password manager or passkey Safari selected in the failed attempt. Both attempts used preferred, so that setting alone does not explain their different outcomes. The failed attempt stopped before we could compare keys. We have not yet tried the initial selection with required using the same passkey.
Issue 2: Same passkey, different encryption keys
In the 1Password QR tests, we supplied the same input to the same passkey through both access methods. Both responses confirmed user verification, but they returned different encryption keys. A message encrypted on one device could not open on the other with the original input.
We also compared outputs for a second input: the original input after one additional hash. The phone’s output for that second input matched the desktop QR output for the original input.
WebAuthn specifies an input hash before the passkey performs its PRF operation: combine a fixed label, a zero byte, and the application’s input, then apply SHA-256. We call that transformation D. WebAuthn PRF specification.
Let x stand for the original input. In the formula below, || means joining the bytes together:
D(x) = SHA256(UTF8("WebAuthn PRF") || 0x00 || x)
We use three labels for the keys we observe:
P(x)is the phone-local output for inputx.Q(x)is the desktop QR output for inputx.S(x)is the direct-desktop output for inputx.
D(x) is still an input, not a key. P(D(x)) means the phone’s output when we supply the extra-hashed input. Each comparison uses one passkey; these labels do not imply shared keys across separate passkeys or providers.
In the 1Password QR comparisons, we measured this relationship:
Q(x) = P(D(x))
We observed the same relationship in an earlier Chrome Password Manager QR comparison, but we have not completed the four-case decryption test for that path. The table below compares keys from the original input. The creator encrypted the message, and the consumer tried to decrypt it.
| Creator | Consumer | Creator key | Consumer key |
|---|---|---|---|
| Chrome QR → 1Password | iPhone Safari → 1Password | Q(x) | P(x) — different |
| iPhone Safari → 1Password | Chrome QR → 1Password | P(x) | Q(x) — different |
| Phone local → Chrome Password Manager | Chrome QR → Chrome Password Manager | P(x) | Q(x) — different |
| iPhone Safari → Apple Passwords | Chrome QR → Apple Passwords | P(x) | Q(x) = P(x) |
| iPhone Safari → Apple Passwords | Desktop Chrome → Apple Passwords directly | P(x) | S(x) = P(x) |
| iPhone Safari → 1Password | Desktop Chrome → 1Password extension | P(x) | S(x) = P(x) |
| iOS Chrome → Chrome Password Manager | Desktop Chrome → Chrome Password Manager directly | P(x) | S(x) = P(x) |
For each complete run, we requested outputs for both inputs—the original value and the extra-hashed value—in one fresh passkey request on each device. The creator encrypted two separate copies of the public message, one with each output. Each encryption used a fresh initialization vector, the extra value AES-GCM needs alongside the key. The consumer then tried each of its two keys against each encrypted message.
Each column below lists the creator input first and the consumer input second.
| Test path | Original / original | Original / extra-hashed | Extra-hashed / original | Extra-hashed / extra-hashed |
|---|---|---|---|---|
| 1Password: phone → desktop QR | Fail | Fail | Pass | Fail |
| 1Password: desktop QR → phone | Fail | Pass | Fail | Fail |
| Apple: phone → desktop QR | Pass | Fail | Fail | Pass |
| Apple: desktop QR → phone | Pass | Fail | Fail | Pass |
| Apple: phone → direct desktop | Pass | Fail | Fail | Pass |
| Apple: direct desktop → phone | Pass | Fail | Fail | Pass |
| Chrome Password Manager: phone → direct desktop | Pass | Fail | Fail | Pass |
| Chrome Password Manager: direct desktop → phone | Pass | Fail | Fail | Pass |
| 1Password: phone → desktop extension | Pass | Fail | Fail | Pass |
| 1Password: desktop extension → phone | Pass | Fail | Fail | Pass |
Apple and the direct-desktop tests passed when both devices used matching inputs. Their crossed-input attempts correctly failed because different inputs selected different keys.
Limits of these results
The matching-key tests do not tell us which component causes the QR difference. One path could omit a required hash, or another could add an extra one. Both explanations fit our measurements.
- We ran the successful direct 1Password tests on Passkey Lab. The earlier QR tests used a different website and different passkeys. We have not compared direct and QR access with the same passkey on the same website, so access method alone does not yet explain the difference.
Within each complete run, both devices used the same passkey. Separate runs used separate test passkeys. We recorded the password managers and access methods manually; the report’s device labels do not prove which ones handled the request.
How we used this to fix Capy Transport
Our implementation now requests PRF outputs for both the original input and the extra-hashed input. We use key material from each output to encrypt a separate copy of the same existing Transport keys. We store both copies in one package. During unlock, we try the available keys. Decryption includes an integrity check that rejects a wrong key. We do not guess the password manager or access method.
After a fresh passkey unlock succeeds, the implementation can automatically add the second encrypted copy to an existing Transport without changing its existing keys. An unlock that reuses keys already in memory does not trigger the upgrade. If a device cannot decrypt the Transport, the user first needs to unlock it with a fresh passkey request on a device that works.
We still need to confirm that this implementation upgrades and unlocks actual Transports with these password managers.
If you want to contribute to Passkey Lab, visit the project on GitHub.