From 79b99cd5d222ce47b2e4be7ac2bc8dcd6dfa4657 Mon Sep 17 00:00:00 2001 From: Siddhi Kotak Date: Mon, 26 Oct 2026 16:38:47 +0530 Subject: [PATCH 1/5] feat(passkey): add granular passkey APIs and update documentation --- EXAMPLES.md | 127 +++++++++++++++++++++++++++++++++++-- __tests__/passkey.test.tsx | 94 +++++++++++++++++++++++++++ src/auth0-context.tsx | 7 ++ src/index.tsx | 5 ++ 4 files changed, 227 insertions(+), 6 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 7e20f4b3..ccfe839f 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1494,10 +1494,13 @@ Passkeys provide password-less authentication using platform biometrics (Face ID - **Signup**: Register a new user with a passkey - **Login**: Authenticate an existing user with a passkey +For full control over the WebAuthn ceremony, you can also use the [granular passkey APIs](#granular-passkey-apis). + - [Setup](#setup) - [Important: Use Refresh Tokens with Passkeys](#important-use-refresh-tokens-with-passkeys) - [Signup with Passkey](#signup-with-passkey) - [Login with Passkey](#login-with-passkey) +- [Granular Passkey APIs](#granular-passkey-apis) - [Identifier Verification](#identifier-verification) - [Complete Passkey Flow Example](#complete-passkey-flow-example) - [Error Handling](#passkey-error-handling) @@ -1565,9 +1568,69 @@ const tokens = await passkey.login(); const tokens = await passkey.login({ realm, organization, scope, audience }); ``` +### Granular Passkey APIs + +If you need control over the WebAuthn ceremony (for example, to show your own UI between steps), use the granular APIs. Get a challenge, run `navigator.credentials.create()` or `navigator.credentials.get()` yourself, then exchange the credential for tokens with `getTokenWithPasskey()`. + +```jsx +import { useAuth0 } from '@auth0/auth0-react'; + +function GranularPasskey() { + const { passkey } = useAuth0(); + + const granularSignup = async (email) => { + const challenge = await passkey.getSignupChallenge({ email }); + + const credential = await navigator.credentials.create({ + publicKey: challenge.publicKey + }); + + if (!credential) { + throw new Error('Credential creation cancelled'); + } + + await passkey.getTokenWithPasskey({ + authSession: challenge.authSession, + credential + }); + // isAuthenticated and user are now updated automatically + }; + + const granularLogin = async () => { + const challenge = await passkey.getLoginChallenge(); + + const credential = await navigator.credentials.get({ + publicKey: challenge.publicKey + }); + + if (!credential) { + throw new Error('Credential request cancelled'); + } + + await passkey.getTokenWithPasskey({ + authSession: challenge.authSession, + credential + }); + }; + + // ... +} +``` + +`getSignupChallenge()` takes the same identifier and context options as `signup()`, and `getLoginChallenge()` the same as `login()`. Pass `realm`, `organization`, `scope` and `audience` to `getTokenWithPasskey()`. + +> [!NOTE] +> `getTokenWithPasskey()` updates `isAuthenticated` and `user` just like `signup()` and `login()`. `getSignupChallenge()` and `getLoginChallenge()` do not change the auth state. + +In TypeScript, `navigator.credentials.create()` and `navigator.credentials.get()` return `Credential | null`. Cast the result to `PublicKeyCredential` before passing it to `getTokenWithPasskey()`. The challenge and options types (`PasskeySignupChallenge`, `PasskeyLoginChallenge`, `PasskeySignupChallengeOptions`, `PasskeyLoginChallengeOptions`, `PasskeyGetTokenOptions`) are exported from `@auth0/auth0-react`. + ### Identifier Verification -Your database connection can ask new users to verify their email or phone number with a one-time code (OTP) during passkey signup. This is off by default and is turned on from the server side. When it's on, the server sends the codes and gives tokens only after they are correct. The user sees the passkey prompt only once, even if they type a wrong code. +Your database connection can ask new users to verify their email or phone number with a one-time code (OTP) during passkey signup. This is off by default and is turned on from the server side. When it's on, the server sends the codes and gives tokens only after they are correct. + +The SDK supports this in both signup flows. The codes are collected **before** the passkey prompt, and the user sees the passkey prompt only once, even if they type a wrong code. + +#### With `signup()` `signup()` stops **before** the passkey prompt and throws `PasskeyVerificationRequiredError`. Its `verificationRequired` property lists the identifiers to verify (for example `['email']`). Ask the user for the codes, then call `continueSignup()` with them: @@ -1632,12 +1695,64 @@ The first `continueSignup()` call shows the passkey prompt. Retries reuse the sa > [!NOTE] > The pending signup is kept in memory only. If the page reloads, `continueSignup()` throws a `PasskeyError` with code `no_pending_signup`. Call `signup()` again to start over. +#### With the granular APIs + +Check `verificationRequired` on the challenge and collect the codes **before** calling `navigator.credentials.create()`. Pass them as `verification` to `getTokenWithPasskey()`. On a retry, send the **same** `credential` with `error.authSession` and new codes for `error.verificationRequired`: + +```jsx +import { useAuth0, PasskeyGetTokenError } from '@auth0/auth0-react'; + +function GranularPasskeySignup() { + const { passkey } = useAuth0(); + + const granularSignupWithVerification = async (email) => { + const challenge = await passkey.getSignupChallenge({ email }); + + // Collect codes first, so the passkey prompt shows only once + let codes = challenge.verificationRequired + ? await askUserForCodes(challenge.verificationRequired) // your UI, e.g. { email: '123456' } + : undefined; + + const credential = await navigator.credentials.create({ + publicKey: challenge.publicKey + }); + + if (!credential) { + throw new Error('Credential creation cancelled'); + } + + let authSession = challenge.authSession; + // The server limits the attempts. Retry only while the error is retryable + for (;;) { + try { + // On success, isAuthenticated and user are updated automatically + return await passkey.getTokenWithPasskey({ + authSession, + credential, // same credential on every retry + verification: codes + }); + } catch (error) { + if (!(error instanceof PasskeyGetTokenError && error.isRetryable)) { + throw error; + } + authSession = error.authSession; + codes = await askUserForCodes(error.verificationRequired); + } + } + }; + + // ... +} +``` + +In TypeScript, `error.authSession` and `error.verificationRequired` are typed as optional. Both are set whenever `isRetryable` is `true`, so you can use `error.authSession!` inside the retry branch. + #### Which errors can be retried | Error | Retry? | What to do | |---|---|---| -| `PasskeyGetTokenError` with `isRetryable: true` (wrong code, session still active) | Yes | The error has `code`, `authSession` and `verificationRequired`. Ask for new codes for `error.verificationRequired` and call `continueSignup()` again | -| `PasskeyError` with code `missing_verification_code` (a code is missing or empty) | Yes | Call `continueSignup()` again with a code for each identifier. Nothing was sent, so the session is still active | +| `PasskeyGetTokenError` with `isRetryable: true` (wrong code, session still active) | Yes | The error has `code`, `authSession` and `verificationRequired`. Ask for new codes for `error.verificationRequired` and call `continueSignup()` again. With the granular APIs, call `getTokenWithPasskey()` again with `error.authSession` and the same `credential` | +| `PasskeyError` with code `missing_verification_code` (a code is missing or empty) | Yes | Call the same method again with a code for each identifier. Nothing was sent, so the session is still active | | `MfaRequiredError` | No | Finish with MFA using `error.mfa_token`. See [Multi-Factor Authentication](#multi-factor-authentication-mfa) | | Any other error, for example an expired code or session, or a `PasskeyGetTokenError` with `isRetryable: false` | No | Start the signup again with `signup()` | @@ -1647,7 +1762,7 @@ The server decides how many attempts are allowed, so don't keep your own retry c #### Phone delivery method -For phone verification, pass `deliveryMethod: 'text'` (SMS) or `'voice'` to `signup()` to choose how the code is sent: +For phone verification, pass `deliveryMethod: 'text'` (SMS) or `'voice'` to `signup()` or `getSignupChallenge()` to choose how the code is sent: ```jsx await passkey.signup({ @@ -1728,9 +1843,9 @@ try { ``` > [!TIP] -> Both `signup()` and `login()` throw an error if the user cancels the biometric prompt. Wrap calls in `try/catch` to handle cancellation, network failures, or misconfigured connections. +> Both `signup()` and `login()` throw an error if the user cancels the biometric prompt. Wrap calls in `try/catch` to handle cancellation, network failures, or misconfigured connections. With the granular APIs, the browser's `navigator.credentials` call throws on cancellation instead (usually a `NotAllowedError` `DOMException`). -When identifier verification is on, `signup()` throws `PasskeyVerificationRequiredError` instead of showing the passkey prompt, and a wrong OTP code makes `continueSignup()` throw a `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for `error.verificationRequired` and call `continueSignup()` again (the same passkey is reused). See [Identifier Verification](#identifier-verification). Other token exchange failures throw a `GenericError` as before (or an `MfaRequiredError` for `mfa_required`). +When identifier verification is on, `signup()` throws `PasskeyVerificationRequiredError` instead of showing the passkey prompt, and a wrong OTP code makes `continueSignup()` or `getTokenWithPasskey()` throw a `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for `error.verificationRequired` and call `continueSignup()` again (the same passkey is reused), or call `getTokenWithPasskey()` again with `error.authSession` and the same `credential`. See [Identifier Verification](#identifier-verification). Other token exchange failures throw a `GenericError` as before (or an `MfaRequiredError` for `mfa_required`). ## MyAccount API diff --git a/__tests__/passkey.test.tsx b/__tests__/passkey.test.tsx index 6d445886..26188211 100644 --- a/__tests__/passkey.test.tsx +++ b/__tests__/passkey.test.tsx @@ -721,5 +721,99 @@ describe('Passkey API', () => { 'You forgot to wrap your component in .' ); }); + + it('should not change auth state when requesting a challenge', async () => { + clientMock.passkey.getSignupChallenge.mockResolvedValueOnce({ + authSession: 'signup-auth-session', + publicKey: {} as PublicKeyCredentialCreationOptions, + verificationRequired: ['email'], + }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + clientMock.getUser.mockResolvedValue(user); + let challenge; + await act(async () => { + challenge = await result.current.passkey.getSignupChallenge({ + email: 'user@example.com', + }); + }); + + expect(challenge).toMatchObject({ verificationRequired: ['email'] }); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeUndefined(); + }); + + it('should forward verification codes to getTokenWithPasskey', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.passkey.getTokenWithPasskey({ + authSession: 'signup-auth-session', + credential, + verification: { email: '123456', phone: '654321' }, + }); + }); + + expect(clientMock.passkey.getTokenWithPasskey).toHaveBeenCalledWith({ + authSession: 'signup-auth-session', + credential, + verification: { email: '123456', phone: '654321' }, + }); + }); + + it('should rethrow a retryable PasskeyGetTokenError unchanged so the same credential can be retried', async () => { + clientMock.passkey.getTokenWithPasskey.mockRejectedValueOnce( + retryableError(['email']) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let error: unknown; + await act(async () => { + try { + await result.current.passkey.getTokenWithPasskey({ + authSession: 'signup-auth-session', + credential, + verification: { email: '000000' }, + }); + } catch (e) { + error = e; + } + }); + + expect(error).toBeInstanceOf(PasskeyGetTokenError); + const tokenError = error as PasskeyGetTokenError; + expect(tokenError.isRetryable).toBe(true); + expect(tokenError.authSession).toBe('auth-session-a'); + expect(tokenError.verificationRequired).toEqual(['email']); + expect(result.current.isAuthenticated).toBe(false); + + clientMock.getUser.mockResolvedValue(user); + await act(async () => { + await result.current.passkey.getTokenWithPasskey({ + authSession: tokenError.authSession!, + credential, + verification: { email: '123456' }, + }); + }); + + expect(clientMock.passkey.getTokenWithPasskey).toHaveBeenLastCalledWith({ + authSession: 'auth-session-a', + credential, + verification: { email: '123456' }, + }); + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toEqual(user); + }); }); }); diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index c6cc946f..cfcb2a46 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -489,6 +489,13 @@ export interface Auth0ContextInterface * The methods that return tokens update `isAuthenticated` / `user` in the * same way as `loginWithPopup`. * + * With the granular APIs, if the signup challenge has + * `verificationRequired`, collect the OTP codes before the WebAuthn + * ceremony and pass them as `verification` to `getTokenWithPasskey()`. A + * wrong code throws a `PasskeyGetTokenError` with `isRetryable: true`; call + * `getTokenWithPasskey()` again with `error.authSession`, the same + * `credential`, and new codes. + * * @category Sub-clients */ passkey: PasskeyApiClient; diff --git a/src/index.tsx b/src/index.tsx index 0e3166d9..ec138937 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -114,6 +114,11 @@ export type { PasskeyApiClient, PasskeySignupOptions, PasskeyLoginOptions, + PasskeySignupChallengeOptions, + PasskeyLoginChallengeOptions, + PasskeySignupChallenge, + PasskeyLoginChallenge, + PasskeyGetTokenOptions, // MyAccount Types MyAccountApiClient, AuthenticationMethod, From 6cfefa4d6970515178205e9d6bd2a055182a73f1 Mon Sep 17 00:00:00 2001 From: Siddhi Kotak Date: Mon, 26 Oct 2026 18:00:40 +0530 Subject: [PATCH 2/5] feat(passkey): enhance granular passkey error handling and update example code --- EXAMPLES.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index ccfe839f..5504f8b6 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1700,7 +1700,11 @@ The first `continueSignup()` call shows the passkey prompt. Retries reuse the sa Check `verificationRequired` on the challenge and collect the codes **before** calling `navigator.credentials.create()`. Pass them as `verification` to `getTokenWithPasskey()`. On a retry, send the **same** `credential` with `error.authSession` and new codes for `error.verificationRequired`: ```jsx -import { useAuth0, PasskeyGetTokenError } from '@auth0/auth0-react'; +import { + useAuth0, + PasskeyError, + PasskeyGetTokenError +} from '@auth0/auth0-react'; function GranularPasskeySignup() { const { passkey } = useAuth0(); @@ -1709,8 +1713,9 @@ function GranularPasskeySignup() { const challenge = await passkey.getSignupChallenge({ email }); // Collect codes first, so the passkey prompt shows only once - let codes = challenge.verificationRequired - ? await askUserForCodes(challenge.verificationRequired) // your UI, e.g. { email: '123456' } + let required = challenge.verificationRequired; + let codes = required + ? await askUserForCodes(required) // your UI, e.g. { email: '123456' } : undefined; const credential = await navigator.credentials.create({ @@ -1732,11 +1737,20 @@ function GranularPasskeySignup() { verification: codes }); } catch (error) { - if (!(error instanceof PasskeyGetTokenError && error.isRetryable)) { + if (error instanceof PasskeyGetTokenError && error.isRetryable) { + // Wrong code: use the new session and ask for these identifiers + authSession = error.authSession; + required = error.verificationRequired; + } else if ( + !( + error instanceof PasskeyError && + error.code === 'missing_verification_code' + ) + ) { throw error; } - authSession = error.authSession; - codes = await askUserForCodes(error.verificationRequired); + // An empty code is never sent, so the same session is still active + codes = await askUserForCodes(required); } } }; @@ -1752,7 +1766,7 @@ In TypeScript, `error.authSession` and `error.verificationRequired` are typed as | Error | Retry? | What to do | |---|---|---| | `PasskeyGetTokenError` with `isRetryable: true` (wrong code, session still active) | Yes | The error has `code`, `authSession` and `verificationRequired`. Ask for new codes for `error.verificationRequired` and call `continueSignup()` again. With the granular APIs, call `getTokenWithPasskey()` again with `error.authSession` and the same `credential` | -| `PasskeyError` with code `missing_verification_code` (a code is missing or empty) | Yes | Call the same method again with a code for each identifier. Nothing was sent, so the session is still active | +| `PasskeyError` with code `missing_verification_code` | Yes | Nothing was sent, so the session is still active. Call the same method again with a code for each identifier. `continueSignup()` throws this when a code is missing or empty. `getTokenWithPasskey()` throws it only for an empty code. If you leave out a required identifier, the request is sent and the server's error is handled as in the other rows | | `MfaRequiredError` | No | Finish with MFA using `error.mfa_token`. See [Multi-Factor Authentication](#multi-factor-authentication-mfa) | | Any other error, for example an expired code or session, or a `PasskeyGetTokenError` with `isRetryable: false` | No | Start the signup again with `signup()` | From c51bee78ad86cdb357e29b52841906a76a5eae3c Mon Sep 17 00:00:00 2001 From: Siddhi Kotak Date: Mon, 26 Oct 2026 18:44:30 +0530 Subject: [PATCH 3/5] docs: update TypeScript example for Granular Passkey to include credential casting --- EXAMPLES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 5504f8b6..a3b33bdb 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1759,7 +1759,7 @@ function GranularPasskeySignup() { } ``` -In TypeScript, `error.authSession` and `error.verificationRequired` are typed as optional. Both are set whenever `isRetryable` is `true`, so you can use `error.authSession!` inside the retry branch. +In TypeScript, cast the `navigator.credentials.create()` result to `PublicKeyCredential | null`, as described in [Granular Passkey APIs](#granular-passkey-apis). `error.authSession` and `error.verificationRequired` are typed as optional. Both are set whenever `isRetryable` is `true`, so you can use `error.authSession!` inside the retry branch. #### Which errors can be retried From 041d2b0c8ffe18af750c2819437f04278dfe9f5b Mon Sep 17 00:00:00 2001 From: Siddhi Kotak Date: Tue, 27 Oct 2026 09:49:47 +0530 Subject: [PATCH 4/5] docs: enhance Granular Passkey example with OTP verification details and error handling --- EXAMPLES.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index a3b33bdb..1a43a2e0 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1580,6 +1580,8 @@ function GranularPasskey() { const granularSignup = async (email) => { const challenge = await passkey.getSignupChallenge({ email }); + // If challenge.verificationRequired is set, collect the OTP codes first. + // See Identifier Verification below const credential = await navigator.credentials.create({ publicKey: challenge.publicKey @@ -1766,13 +1768,13 @@ In TypeScript, cast the `navigator.credentials.create()` result to `PublicKeyCre | Error | Retry? | What to do | |---|---|---| | `PasskeyGetTokenError` with `isRetryable: true` (wrong code, session still active) | Yes | The error has `code`, `authSession` and `verificationRequired`. Ask for new codes for `error.verificationRequired` and call `continueSignup()` again. With the granular APIs, call `getTokenWithPasskey()` again with `error.authSession` and the same `credential` | -| `PasskeyError` with code `missing_verification_code` | Yes | Nothing was sent, so the session is still active. Call the same method again with a code for each identifier. `continueSignup()` throws this when a code is missing or empty. `getTokenWithPasskey()` throws it only for an empty code. If you leave out a required identifier, the request is sent and the server's error is handled as in the other rows | +| `PasskeyError` with code `missing_verification_code` | Yes | Nothing was sent, so the session is still active. Call the same method again with a code for each identifier. Always send a code for every identifier in `verificationRequired`: `continueSignup()` checks for missing and empty codes, but `getTokenWithPasskey()` only checks for empty ones. If you leave out an identifier, the call goes to the server and the user may have to start over | | `MfaRequiredError` | No | Finish with MFA using `error.mfa_token`. See [Multi-Factor Authentication](#multi-factor-authentication-mfa) | -| Any other error, for example an expired code or session, or a `PasskeyGetTokenError` with `isRetryable: false` | No | Start the signup again with `signup()` | +| Any other error, for example an expired code or session, or a `PasskeyGetTokenError` with `isRetryable: false` | No | Start the signup again with `signup()`, or with `getSignupChallenge()` for the granular flow | -The server decides how many attempts are allowed, so don't keep your own retry count. Retry for as long as `isRetryable` is `true`. Once the server ends the session, the error no longer has an `authSession`, `isRetryable` is `false`, and you need to call `signup()` again. +The server decides how many attempts are allowed, so don't keep your own retry count. Retry for as long as `isRetryable` is `true`. Once the server ends the session, the error no longer has an `authSession`, `isRetryable` is `false`, and you need to call `signup()` (or `getSignupChallenge()`) again. #### Phone delivery method From 4dd1c39f54c07976cf0d6d57ac5a183e98037887 Mon Sep 17 00:00:00 2001 From: Siddhi Kotak Date: Tue, 27 Oct 2026 10:04:20 +0530 Subject: [PATCH 5/5] docs: clarify OTP collection process in Granular Passkey example --- EXAMPLES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1a43a2e0..8bb3951a 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1580,7 +1580,7 @@ function GranularPasskey() { const granularSignup = async (email) => { const challenge = await passkey.getSignupChallenge({ email }); - // If challenge.verificationRequired is set, collect the OTP codes first. + // If challenge.verificationRequired is set, collect the OTP codes first and pass them as `verification` to getTokenWithPasskey(). // See Identifier Verification below const credential = await navigator.credentials.create({