[ECUK In-App 3DS] Add Passkeys/WebAuthn support for multifactor biometric authentication#84610
Conversation
Extract biometrics logic from Context/useNativeBiometrics into a dedicated biometrics/ module with platform-specific implementations (useBiometrics.ts for native, useBiometrics.web.ts for web). Add passkey stub implementation, WebAuthn error handling, and include PASSKEYS in allowed 3DS auth methods.
Implement WebAuthn-based passkey registration and authentication as a new MFA method alongside existing biometrics. Add passkey scenario configuration, translations, and SecureStore integration.
Strengthen source types in RegistrationChallenge and AuthenticationChallenge to use PublicKeyCredentialType and UserVerificationRequirement. Replace PublicKeyCredential casts with instanceof type guard. Derive SupportedTransport from CONST.PASSKEY_TRANSPORT with runtime guard for type narrowing.
Replace as casts for AuthenticatorAttestationResponse and AuthenticatorAssertionResponse with instanceof type guards. Use isSupportedTransport guard for getTransports() filtering.
Private keys should not leak beyond the registration function. For native biometrics the key is stored in SecureStore internally, for passkeys it never leaves the authenticator. No consumer of RegisterResult ever reads privateKey.
These debug logs were development artifacts that could leak sensitive authentication data (credential IDs, challenge content, backend responses) to the browser console in production.
Passkeys don't use SecureStore at all — the PASSKEY entry was misplaced. Define PASSKEY_AUTH_TYPE in WebAuthn.ts, make AuthTypeInfo.code optional (only relevant for native biometrics), and update derived types to include passkey via union.
|
Hey, I noticed you changed If you want to automatically generate translations for other locales, an Expensify employee will have to:
Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running: npx ts-node ./scripts/generateTranslations.ts --helpTypically, you'd want to translate only what you changed by running |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Reconcile server-known credential IDs with local Onyx credentials and pass the result as excludeCredentials to navigator.credentials.create, preventing the same authenticator from being registered twice.
…eTypes.ts These types (SignedChallenge, RegistrationChallenge, AuthenticationChallenge, etc.) are algorithm-agnostic and shared by both ED25519 (native biometrics) and ES256 (passkeys) flows. Moving them to a neutral location removes the misleading coupling to the ED25519 module.
Separate platform-specific logic into dedicated modules: - NativeBiometrics: Expo SecureStore, ED25519, native key management - Passkeys: WebAuthn API, passkey credential helpers - shared: challenge types, observers, HTTP helpers, common types Update all imports across source and test files.
JakubKorytko
left a comment
There was a problem hiding this comment.
some NITs, overall LGTM
tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts
Outdated
Show resolved
Hide resolved
src/components/MultifactorAuthentication/biometrics/usePasskeys.ts
Outdated
Show resolved
Hide resolved
… tests Remove unnecessary KeyStore mock, add tests for processPasskeyRegistration and processScenarioAction to cover the full processing module.
- Rename biometrics/common/ to biometrics/shared/ for consistency - Use spread in VALUES.ts barrel file - Rename MultifactorAuthenticationKeyInfo to NativeBiometricsKeyInfo - Replace `as` with type guard in Passkeys/helpers.ts - Remove redundant credentials.length check in usePasskeys - Rename (p) to (param) in WebAuthn.ts - Split shared/helpers.test.ts into NativeBiometrics/ and shared/ dirs - Unify processRegistration and processPasskeyRegistration: hooks now build keyInfo internally, processing.ts has one shared function
…s/passkeys-mfa # Conflicts: # src/components/MultifactorAuthentication/Context/Main.tsx # src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts # src/libs/MultifactorAuthentication/shared/VALUES.ts
18da419 to
8171acc
Compare
…tion Expose a platform-specific constant (BIOMETRICS on native, PASSKEYS on web) so consumers can check whether the scenario allows the current device's verification type, replacing the redundant switch-case fallthrough.
…r wrapper React Compiler handles memoization automatically, so useMemo and the module-level selector function are unnecessary.
Replace magic number -8 with CONST.COSE_ALGORITHM.EDDSA in types and runtime code. Add ES256 and RS256 constants for passkey use.
|
That worked!! iOS Safari: success ✅ios.safari.passkey.success.mp4 |
Screenshots/VideosAndroid: mWeb ChromeStill got failed iOS: mWeb SafariStill got failed Screen.Recording.2026-03-20.at.01.08.42.mov |
|
🚧 @rafecolton has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
rafecolton
left a comment
There was a problem hiding this comment.
@Expensify/design please review 🙏
|
This has conflicts now, probably from #84120 being merged. Once those are resolved and Jon's design comments are addressed, this can be merged 👍 |
3fb6ae5
|
I have made these changes to this PR:
If you decide to do this, I will be very grateful if you send me the appropriate SVG, because I currently cannot find this icon variant in Figma:
passkeys.mov |
|
Agree with all your feedback Jon! |
|
@dannymcclain
|
|
Try this one! @dubielzyk-expensify I made some tweaks to harmonize the colors a bit more 🙃 🤷♂️ (also published this version in our Simple Illustrations file)
|
|
🚧 @rafecolton has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚀 Deployed to staging by https://github.com/rafecolton in version: 9.3.42-0 🚀
Bundle Size Analysis (Sentry): |





Explanation of Change
This PR adds Passkeys/WebAuthn as a new authentication method for in-app 3DS card transaction authorization on Web/mWeb, alongside the existing NativeBiometrics (iOS/Android). A platform-specific
useBiometrics()hook abstracts both methods behind a sharedUseBiometricsReturninterface so the MFA context is unaware of the underlying technology. TheBiometrics/module is refactored intoNativeBiometrics/,Passkeys/, andshared/directories. Passkeys are currently enabled only in the BiometricsTest scenario. Localization and tests are updated accordingly.Detailed breakdown
usePasskeyshook — Manages the full Passkeys lifecycle (registration and authorization) by bridging low-level WebAuthn browser APIs with theMultifactorAuthenticationContext. Handles frontend ↔ backend credential exchange including challenge request/response flows.Passkeys/WebAuthn.tsprovidesArrayBuffer↔base64urlconversions, buildsPublicKeyCredentialCreationOptions/RequestOptions, and wrapsnavigator.credentials.create/get. A separatePasskeys/helpers.tsmodule maps common WebAuthnDOMExceptionerrors to internal error codes.UseBiometricsReturn,RegisterResult,AuthorizeResult,AuthorizeParams) and auseServerCredentialshook intobiometrics/shared/so that bothuseNativeBiometricsandusePasskeysshare the same contract with the MFA context.useBiometricssplit —biometrics/useBiometrics/index.ts(web) re-exportsusePasskeys, whileindex.native.tsre-exportsuseNativeBiometrics, so theMultifactorAuthenticationContextcalls a singleuseBiometrics()hook and the platform decides which implementation runs.MultifactorAuthenticationContextextension — Wires in the newuseBiometrics()abstraction. Device eligibility is now checked in two steps: (2a)doesDeviceSupportAuthenticationMethod()verifies platform support, and (2b) the scenario'sallowedAuthenticationMethodsarray is checked againstdeviceVerificationType. Prompt navigation now uses aPROMPT_TYPE_MAPto pick the correct prompt type (biometrics vs passkeys).allowedAuthenticationMethodsfrom[BIOMETRICS]to[BIOMETRICS, PASSKEYS]. Other scenarios remain unchanged, so Passkeys are currently only available in the test flow.Biometrics/directory toNativeBiometrics/and extracts native-specific types, helpers (Expo error decoding), and VALUES into dedicated files. Shared types and challenge types are moved toshared/. A new barrel file (MultifactorAuthentication/VALUES.ts) merges shared + NativeBiometrics + Passkeys values. The unusedObserver.tsandMultifactorAuthenticationCallbacksare removed.keyInfoconstruction (previously inprocessing.tsviacreateKeyInfoObject) is now performed inside each hook (useNativeBiometrics/usePasskeys), andprocessRegistrationreceives the ready-madekeyInfoobject directly.PromptContent.tsxnow supports both Lottie animations and SVG illustrations (animationprop renamed toillustration). Passkeys use a new SVG asset (simple-illustration__encryption-passkeys.svg); native biometrics continue using the existing Lottie fingerprint animation.localPublicKey→localCredentialID,getLocalPublicKey→getLocalCredentialID,doesDeviceSupportBiometrics→doesDeviceSupportAuthenticationMethod,resetKeysForAccount→deleteLocalKeysForAccountacross hooks, pages, selectors, and tests.useNavigateTo3DSAuthorizationChallengereplaces the per-methodswitch-case with a singledoesDeviceSupportAuthenticationMethod() && allowedAuthenticationMethods.includes(deviceVerificationType)check, removing the previous TODO comment about passkey support.userVerificationnarrowed fromstringtoUserVerificationRequirement,pubKeyCredParams.typenarrowed fromstringtoPublicKeyCredentialType,AuthTypeInfo.codemade optional (passkeys don't use SecureStore codes). AddedCOSE_ALGORITHMconstants (EDDSA,ES256,RS256) toCONST.hasBiometricsRegisteredSelectorandisAccountLoadingSelectorfromselectors/Account.ts; addedmfaCredentialIDsSelectorused by the newuseServerCredentialshook.verifyYourself.passkeys,enableQuickVerification.passkeys,authType.passkey) across all supported languages.processRegistration/processScenarioAction(processing.test.ts), NativeBiometrics Expo error decoding (NativeBiometrics/helpers.test.ts), and sharedparseHttpRequest(shared/helpers.test.ts). Updates existing test suites for renamed fields and import paths. Removes obsoleteObserver.test.tsand oldBiometrics/helpers.test.ts.Fixed Issues
$ #79464
$ #79467
$ #79469
PROPOSAL:
Tests
All of these PRs have already been deployed to production:
Passkeys registration (Web / mWeb):
registeredlabel is now displayedPasskeys authorization (Web / mWeb): 10. Repeat steps 3 and 4 11. Verify that the validate code step is no longer required 12. Authenticate using the passkey prompt 13. Verify that the Authentication was successful
Failure scenarios (Web / mWeb): 14. Cancel the passkey prompt during registration (step 7) — verify the Authentication failed 15. Cancel the passkey prompt during authorization (step 12) — verify the Authentication failed 16. Exit the flow using the back button or by clicking the overlay — verify a confirmation modal is displayed 17. Enter a wrong validate code during registration — verify an error text is displayed and the magic code input is shown again to allow re-entry
NativeBiometrics regression (iOS / Android native): 18. Open Expensify App on a native mobile device 19. Navigate to Settings → Troubleshoot 20. Click on the "Test" button next to the "Biometrics (Not registered)" text 21. Click on the "Test" button in the RHP view 22. Fill the magic code input 23. Click on the "Got it" button 24. Authenticate using device credentials or biometry (Face ID / Touch ID / fingerprint) 25. Verify that the Authentication was successful 26. Verify that the
registeredlabel is now displayed 27. Repeat steps 20 and 21 28. Verify that validate code is no longer required 29. Authenticate using device biometrics 30. Verify that the Authentication was successfulOffline tests
N/A,
D - Full Page Blocking UI Patternfor this project.QA Steps
Same as tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari