diff --git a/.changeset/passkey-second-factor.md b/.changeset/passkey-second-factor.md
new file mode 100644
index 00000000000..902844d9ef9
--- /dev/null
+++ b/.changeset/passkey-second-factor.md
@@ -0,0 +1,25 @@
+---
+'@clerk/shared': minor
+'@clerk/clerk-js': minor
+'@clerk/ui': minor
+'@clerk/localizations': minor
+'@clerk/react': minor
+---
+
+Support passkeys as a second factor during sign-in and session reverification.
+
+When a user has a passkey, the prebuilt `` and ``
+components preselect it as the second factor, as they already do for the first
+factor. Other methods stay available under "Use another method".
+
+To use it, your instance must allow passkeys to satisfy the second factor. This
+is on by default for instances created after 2026-07-08, and can be turned on
+in the Dashboard for older ones. Web apps also need to opt in to the passkey
+second factor update in the Dashboard.
+
+If you build a custom flow, `supportedSecondFactors` can now include `passkey`.
+Call `signIn.authenticateWithPasskey()` to complete it.
+
+Passkeys are no longer preselected, for either factor, where the SDK reports
+that WebAuthn is unavailable (for example, Electron apps without passkey
+support enabled).
diff --git a/integration/configs/with-passkeys.js b/integration/configs/with-passkeys.js
index 056a488a030..07971f84059 100644
--- a/integration/configs/with-passkeys.js
+++ b/integration/configs/with-passkeys.js
@@ -16,6 +16,12 @@ export default defineConfig({
},
auth_passkey: {
used_for_sign_in: true,
+ satisfies_second_factor: true,
+ },
+ auth_multi_factor: {
+ authenticator_app: {
+ enabled: true,
+ },
},
},
});
diff --git a/integration/tests/passkeys.test.ts b/integration/tests/passkeys.test.ts
index 4ad96a6905e..f079940e8f2 100644
--- a/integration/tests/passkeys.test.ts
+++ b/integration/tests/passkeys.test.ts
@@ -1,4 +1,4 @@
-import type { BrowserContext } from '@playwright/test';
+import type { BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Application } from '../models/application';
@@ -14,6 +14,9 @@ type SavedCredential = {
publicKey: string;
};
+// Any valid base32 secret works — no test ever generates a code from it.
+const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
+
// clerk-js gates WebAuthn behind isValidBrowser(), which bails when
// navigator.webdriver is true, so mask it before installing the virtual authenticator.
const installVirtualAuthenticator = async (context: BrowserContext) => {
@@ -23,18 +26,26 @@ const installVirtualAuthenticator = async (context: BrowserContext) => {
await context.credentials.install();
};
+let app: Application;
+
+test.beforeAll(async () => {
+ app = await appConfigs.next.appRouter.commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withPasskeys);
+ await app.dev();
+});
+
+test.afterAll(async () => {
+ await app.teardown();
+});
+
test.describe('passkeys @generic', () => {
test.describe.configure({ mode: 'serial' });
- let app: Application;
let fakeUser: FakeUser;
let savedCredential: SavedCredential;
test.beforeAll(async () => {
- app = await appConfigs.next.appRouter.commit();
- await app.setup();
- await app.withEnv(appConfigs.envs.withPasskeys);
- await app.dev();
const u = await createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test, { fictionalEmail: true, withPassword: true });
await u.services.users.createBapiUser(fakeUser);
@@ -42,7 +53,6 @@ test.describe('passkeys @generic', () => {
test.afterAll(async () => {
await fakeUser.deleteIfExists();
- await app.teardown();
});
test('registers a passkey through UserProfile', async ({ page, context }) => {
@@ -108,3 +118,91 @@ test.describe('passkeys @generic', () => {
await u.po.expect.toBeSignedIn();
});
});
+
+test.describe('passkeys as a second factor @generic', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+ let userId: string;
+ let savedCredential: SavedCredential;
+
+ test.beforeAll(async () => {
+ const u = await createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser(test, { fictionalEmail: true, withPassword: true });
+ const user = await u.services.users.createBapiUser(fakeUser);
+ userId = user.id;
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ });
+
+ const signInToSecondFactor = async (page: Page, context: BrowserContext) => {
+ const u = await createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({
+ email: fakeUser.email!,
+ password: fakeUser.password,
+ waitForSession: false,
+ });
+ await u.page.waitForURL(/\/sign-in\/factor-two/);
+ return u;
+ };
+
+ test('registers a passkey, then enrolls the user in two-factor', async ({ page, context }) => {
+ await installVirtualAuthenticator(context);
+
+ const u = await createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email!, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.po.userProfile.goTo();
+ await u.po.userProfile.switchToSecurityTab();
+ await u.page.getByRole('button', { name: /add a passkey/i }).click();
+
+ await expect(u.page.locator('.cl-profileSectionItem__passkeys')).toBeVisible();
+
+ const credentials = await context.credentials.get();
+ expect(credentials).toHaveLength(1);
+ savedCredential = credentials[0];
+
+ // A passkey on its own never creates a 2FA requirement, so enroll TOTP to park
+ // later sign-ins at needs_second_factor. It has to happen after the passkey is
+ // registered, since the remaining tests never enter a TOTP code.
+ await u.services.clerk.users.updateUser(userId, { totpSecret: TOTP_SECRET });
+ });
+
+ test('offers the passkey as the starting second factor', async ({ page, context }) => {
+ await installVirtualAuthenticator(context);
+
+ const u = await signInToSecondFactor(page, context);
+
+ // Passkey outranks the enrolled authenticator app once the backend advertises it.
+ await expect(u.page.getByText('Use your passkey')).toBeVisible();
+ // Seed only now: the start page's conditional-UI autofill would otherwise
+ // answer with this credential and verify it as the first factor instead.
+ await context.credentials.create(savedCredential.rpId, savedCredential);
+ await u.po.signIn.continue();
+
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('lists the passkey under "Use another method"', async ({ page, context }) => {
+ await installVirtualAuthenticator(context);
+
+ const u = await signInToSecondFactor(page, context);
+
+ await u.po.signIn.getUseAnotherMethodLink().click();
+
+ const passkeyButton = u.page.getByRole('button', { name: /sign in with your passkey/i });
+ await expect(passkeyButton).toBeVisible();
+ await expect(u.page.getByRole('button', { name: /use your authenticator app/i })).toBeVisible();
+
+ await context.credentials.create(savedCredential.rpId, savedCredential);
+ await passkeyButton.click();
+ await u.po.signIn.continue();
+
+ await u.po.expect.toBeSignedIn();
+ });
+});
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index ce853a560ba..1823d6b7b5b 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -3,8 +3,8 @@
{ "path": "./dist/clerk.js", "maxSize": "556KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "83KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" },
- { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" },
- { "path": "./dist/clerk.native.js", "maxSize": "82KB" },
+ { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.75KB" },
+ { "path": "./dist/clerk.native.js", "maxSize": "82.5KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts
index dd80e84be0b..e9078a363e3 100644
--- a/packages/clerk-js/src/core/resources/Session.ts
+++ b/packages/clerk-js/src/core/resources/Session.ts
@@ -37,6 +37,7 @@ import type {
SessionVerifyCreateParams,
SessionVerifyPrepareFirstFactorParams,
SessionVerifyPrepareSecondFactorParams,
+ SessionVerifyWithPasskeyParams,
TokenResource,
UserResource,
} from '@clerk/shared/types';
@@ -47,7 +48,7 @@ import { debugLogger } from '@/utils/debug';
import { getTabState, isTabFocused } from '@/utils/isTabFocused';
import { TokenId } from '@/utils/tokenId';
-import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors';
+import { clerkInvalidStrategy, clerkInvalidVerificationLevel, clerkMissingWebAuthnPublicKeyOptions } from '../errors';
import { eventBus, events } from '../events';
import type { FapiResponseJSON } from '../fapiClient';
import { SessionTokenCache } from '../tokenCache';
@@ -324,10 +325,29 @@ export class Session extends BaseResource implements SessionResource {
return new SessionVerification(json);
};
- verifyWithPasskey = async (): Promise => {
- const prepareResponse = await this.prepareFirstFactorVerification({ strategy: 'passkey' });
+ /**
+ * Initiates a reverification flow using passkeys.
+ *
+ * By default the passkey verifies the first factor. Pass `{ level: 'second_factor' }` to satisfy
+ * the second factor of an in-progress multi-factor reverification instead. Any other `level`
+ * value is rejected.
+ *
+ * Throws a `ClerkWebAuthnError` when WebAuthn is unsupported or the passkey ceremony fails.
+ * @returns A `SessionVerification` instance with its status and supported factors.
+ */
+ verifyWithPasskey = async (params?: SessionVerifyWithPasskeyParams): Promise => {
+ const level = params?.level ?? 'first_factor';
+ if (level !== 'first_factor' && level !== 'second_factor') {
+ clerkInvalidVerificationLevel('Session.verifyWithPasskey', level);
+ }
+
+ const prepareResponse =
+ level === 'second_factor'
+ ? await this.prepareSecondFactorVerification({ strategy: 'passkey' })
+ : await this.prepareFirstFactorVerification({ strategy: 'passkey' });
- const { nonce = null } = prepareResponse.firstFactorVerification;
+ const { nonce = null } =
+ level === 'second_factor' ? prepareResponse.secondFactorVerification : prepareResponse.firstFactorVerification;
/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
@@ -357,6 +377,13 @@ export class Session extends BaseResource implements SessionResource {
throw error;
}
+ if (level === 'second_factor') {
+ return this.attemptSecondFactorVerification({
+ strategy: 'passkey',
+ publicKeyCredential,
+ });
+ }
+
return this.attemptFirstFactorVerification({
strategy: 'passkey',
publicKeyCredential,
@@ -380,11 +407,23 @@ export class Session extends BaseResource implements SessionResource {
attemptSecondFactorVerification = async (
attemptFactor: SessionVerifyAttemptSecondFactorParams,
): Promise => {
+ let config;
+ switch (attemptFactor.strategy) {
+ case 'passkey': {
+ config = {
+ publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential)),
+ };
+ break;
+ }
+ default:
+ config = { ...attemptFactor };
+ }
+
const json = (
await BaseResource._fetch({
method: 'POST',
path: `/client/sessions/${this.id}/verify/attempt_second_factor`,
- body: attemptFactor as any,
+ body: { ...config, strategy: attemptFactor.strategy } as any,
})
)?.response as unknown as SessionVerificationJSON;
diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts
index 94a4916c196..7b12a95f3c4 100644
--- a/packages/clerk-js/src/core/resources/SignIn.ts
+++ b/packages/clerk-js/src/core/resources/SignIn.ts
@@ -402,8 +402,19 @@ export class SignIn extends BaseResource implements SignInResource {
attemptSecondFactor = (params: AttemptSecondFactorParams): Promise => {
debugLogger.debug('SignIn.attemptSecondFactor', { id: this.id, strategy: params.strategy });
+ let config;
+ switch (params.strategy) {
+ case 'passkey':
+ config = {
+ publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(params.publicKeyCredential)),
+ };
+ break;
+ default:
+ config = { ...params };
+ }
+
return this._basePost({
- body: params,
+ body: { ...config, strategy: params.strategy },
action: 'attempt_second_factor',
});
};
@@ -622,6 +633,25 @@ export class SignIn extends BaseResource implements SignInResource {
});
};
+ /**
+ * Authenticates the sign-in with a passkey.
+ *
+ * When called without `params.flow` while the sign-in status is `needs_second_factor` (or
+ * `needs_client_trust`) and the sign-in offers `passkey` among its `supportedSecondFactors`, the
+ * passkey acts as the second factor: the in-progress sign-in is reused via the discrete
+ * prepare/attempt second-factor flow.
+ *
+ * Otherwise the passkey verifies the first factor, with `params.flow` selecting how the ceremony
+ * starts: `'autofill'`/`'discoverable'` create a new sign-in and identify the user from the
+ * passkey itself, discarding any in-progress sign-in, while the default requires a sign-in
+ * created beforehand. A sign-in parked at a second-factor status that does NOT offer passkey (the
+ * backend advertises it only when the instance allows passkeys to satisfy the second factor, the
+ * user has one registered, and the client version supports it) also takes this path, matching
+ * clients that predate passkey second factors: the ceremony starts over instead of failing.
+ *
+ * Throws a `ClerkWebAuthnError` when WebAuthn is unsupported or the passkey ceremony fails.
+ * @returns The updated `SignIn` resource.
+ */
public authenticateWithPasskey = async (params?: AuthenticateWithPasskeyParams): Promise => {
const { flow } = params || {};
@@ -641,6 +671,33 @@ export class SignIn extends BaseResource implements SignInResource {
});
}
+ const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust';
+ const hasPasskeySecondFactor = (this.supportedSecondFactors || []).some(f => f.strategy === 'passkey');
+ if (!flow && isSecondFactor && hasPasskeySecondFactor) {
+ await this.prepareSecondFactor({ strategy: 'passkey' });
+
+ const { nonce: secondFactorNonce } = this.secondFactorVerification;
+ const secondFactorPublicKeyOptions = secondFactorNonce
+ ? convertJSONToPublicKeyRequestOptions(JSON.parse(secondFactorNonce))
+ : null;
+ if (!secondFactorPublicKeyOptions) {
+ clerkMissingWebAuthnPublicKeyOptions('get');
+ }
+
+ const { publicKeyCredential, error } = await webAuthnGetCredential({
+ publicKeyOptions: secondFactorPublicKeyOptions,
+ conditionalUI: false,
+ });
+ if (!publicKeyCredential) {
+ throw error;
+ }
+
+ return this.attemptSecondFactor({
+ publicKeyCredential,
+ strategy: 'passkey',
+ });
+ }
+
if (flow === 'autofill' || flow === 'discoverable') {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
await this.create({ strategy: 'passkey' });
@@ -864,6 +921,7 @@ class SignInFuture implements SignInFutureResource {
verifyEmailCode: this.verifyMFAEmailCode.bind(this),
verifyTOTP: this.verifyTOTP.bind(this),
verifyBackupCode: this.verifyBackupCode.bind(this),
+ passkey: this.verifyMFAPasskey.bind(this),
};
#canBeDiscarded = false;
@@ -1643,6 +1701,56 @@ class SignInFuture implements SignInFutureResource {
});
}
+ async verifyMFAPasskey(): Promise<{ error: ClerkError | null }> {
+ /**
+ * The UI should always prevent from this method being called if WebAuthn is not supported.
+ * As a precaution we need to check if WebAuthn is supported.
+ */
+ const isWebAuthnSupported = SignIn.clerk.__internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow;
+ const webAuthnGetCredential = SignIn.clerk.__internal_getPublicCredentials || webAuthnGetCredentialOnWindow;
+
+ if (!isWebAuthnSupported()) {
+ throw new ClerkWebAuthnError('Passkeys are not supported', {
+ code: 'passkey_not_supported',
+ });
+ }
+
+ return runAsyncResourceTask(this.#resource, async () => {
+ const passkeyFactor = this.#resource.supportedSecondFactors?.find(f => f.strategy === 'passkey');
+ if (!passkeyFactor) {
+ throw new ClerkRuntimeError('Passkey factor not found', { code: 'factor_not_found' });
+ }
+
+ await this.#resource.__internal_basePost({
+ body: { strategy: 'passkey' },
+ action: 'prepare_second_factor',
+ coalesce: true,
+ });
+
+ const { nonce } = this.#resource.secondFactorVerification;
+ const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;
+ if (!publicKeyOptions) {
+ throw new ClerkRuntimeError('Missing public key options', { code: 'missing_public_key_options' });
+ }
+
+ const { publicKeyCredential, error } = await webAuthnGetCredential({
+ publicKeyOptions,
+ conditionalUI: false,
+ });
+ if (!publicKeyCredential) {
+ throw new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_failed' });
+ }
+
+ await this.#resource.__internal_basePost({
+ body: {
+ publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)),
+ strategy: 'passkey',
+ },
+ action: 'attempt_second_factor',
+ });
+ });
+ }
+
async ticket(params?: SignInFutureTicketParams): Promise<{ error: ClerkError | null }> {
const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket');
return this.create({ strategy: 'ticket', ticket: ticket ?? undefined });
diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts
index 1c57c44e7c3..e733406e3c9 100644
--- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts
@@ -896,6 +896,137 @@ describe('Session', () => {
});
});
+ describe('verifyWithPasskey()', () => {
+ const mockPublicKeyCredential = {
+ id: 'credential_123',
+ rawId: new ArrayBuffer(32),
+ response: {
+ authenticatorData: new ArrayBuffer(37),
+ clientDataJSON: new ArrayBuffer(121),
+ signature: new ArrayBuffer(64),
+ userHandle: null,
+ },
+ type: 'public-key',
+ };
+
+ const sessionJSON = {
+ status: 'active',
+ id: 'session_1',
+ object: 'session',
+ user: createUser({}),
+ last_active_organization_id: null,
+ actor: null,
+ created_at: new Date().getTime(),
+ updated_at: new Date().getTime(),
+ } as SessionJSON;
+
+ let originalFetch: typeof BaseResource._fetch;
+
+ beforeEach(() => {
+ originalFetch = BaseResource._fetch;
+ BaseResource.clerk = clerkMock({
+ __internal_isWebAuthnSupported: vi.fn().mockReturnValue(true),
+ __internal_getPublicCredentials: vi.fn().mockResolvedValue({
+ publicKeyCredential: mockPublicKeyCredential,
+ error: null,
+ }),
+ } as any);
+ });
+
+ afterEach(() => {
+ BaseResource._fetch = originalFetch;
+ BaseResource.clerk = null as any;
+ });
+
+ it('runs the first-factor verification flow by default', async () => {
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ response: {
+ id: 'sv_123',
+ first_factor_verification: { nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }) },
+ },
+ })
+ .mockResolvedValueOnce({
+ response: { id: 'sv_123', status: 'complete' },
+ });
+ BaseResource._fetch = mockFetch;
+
+ const session = new Session(sessionJSON);
+ await session.verifyWithPasskey();
+
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sessions/session_1/verify/prepare_first_factor',
+ body: expect.objectContaining({ strategy: 'passkey' }),
+ }),
+ );
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sessions/session_1/verify/attempt_first_factor',
+ body: expect.objectContaining({
+ strategy: 'passkey',
+ publicKeyCredential: expect.any(String),
+ }),
+ }),
+ );
+ });
+
+ it('runs the second-factor verification flow when level is second_factor', async () => {
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ response: {
+ id: 'sv_123',
+ second_factor_verification: { nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }) },
+ },
+ })
+ .mockResolvedValueOnce({
+ response: { id: 'sv_123', status: 'complete' },
+ });
+ BaseResource._fetch = mockFetch;
+
+ const session = new Session(sessionJSON);
+ await session.verifyWithPasskey({ level: 'second_factor' });
+
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sessions/session_1/verify/prepare_second_factor',
+ body: expect.objectContaining({ strategy: 'passkey' }),
+ }),
+ );
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sessions/session_1/verify/attempt_second_factor',
+ body: expect.objectContaining({
+ strategy: 'passkey',
+ publicKeyCredential: expect.any(String),
+ }),
+ }),
+ );
+ });
+
+ it('rejects invalid verification levels without calling the API', async () => {
+ const mockFetch = vi.fn();
+ BaseResource._fetch = mockFetch;
+
+ const session = new Session(sessionJSON);
+ await expect(session.verifyWithPasskey({ level: 'third_factor' as any })).rejects.toThrow(
+ 'not a valid verification level',
+ );
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+ });
+
describe('touch()', () => {
let dispatchSpy: ReturnType;
diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
index 7942dfcc7ac..d8c3abe7fb4 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
@@ -763,6 +763,215 @@ describe('SignIn', () => {
});
});
+ describe('authenticateWithPasskey', () => {
+ const mockPublicKeyCredential = {
+ id: 'credential_123',
+ rawId: new ArrayBuffer(32),
+ response: {
+ authenticatorData: new ArrayBuffer(37),
+ clientDataJSON: new ArrayBuffer(121),
+ signature: new ArrayBuffer(64),
+ userHandle: null,
+ },
+ type: 'public-key',
+ };
+
+ const originalFetch = BaseResource._fetch;
+ const originalClerk = SignIn.clerk;
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ BaseResource._fetch = originalFetch;
+ SignIn.clerk = originalClerk;
+ });
+
+ it('prepares and attempts the second factor when the sign-in needs a second factor', async () => {
+ const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
+ const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({
+ publicKeyCredential: mockPublicKeyCredential,
+ error: null,
+ });
+
+ SignIn.clerk = {
+ __internal_isWebAuthnSupported: mockIsWebAuthnSupported,
+ __internal_getPublicCredentials: mockWebAuthnGetCredential,
+ } as any;
+
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ client: null,
+ response: {
+ id: 'signin_123',
+ status: 'needs_second_factor',
+ supported_second_factors: [{ strategy: 'passkey' }],
+ second_factor_verification: {
+ nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }),
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ client: null,
+ response: { id: 'signin_123', status: 'complete' },
+ });
+ BaseResource._fetch = mockFetch;
+
+ const signIn = new SignIn({
+ id: 'signin_123',
+ status: 'needs_second_factor',
+ supported_second_factors: [{ strategy: 'passkey' }, { strategy: 'totp' }],
+ } as any);
+ await signIn.authenticateWithPasskey();
+
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sign_ins/signin_123/prepare_second_factor',
+ body: expect.objectContaining({ strategy: 'passkey' }),
+ }),
+ );
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sign_ins/signin_123/attempt_second_factor',
+ body: expect.objectContaining({
+ strategy: 'passkey',
+ publicKeyCredential: expect.any(String),
+ }),
+ }),
+ );
+ });
+
+ it.each(['autofill', 'discoverable'] as const)(
+ 'creates a new sign-in for the %s flow even when the sign-in needs a passkey second factor',
+ async flow => {
+ const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
+ const mockIsWebAuthnAutofillSupported = vi.fn().mockResolvedValue(true);
+ const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({
+ publicKeyCredential: mockPublicKeyCredential,
+ error: null,
+ });
+
+ SignIn.clerk = {
+ __internal_isWebAuthnSupported: mockIsWebAuthnSupported,
+ __internal_isWebAuthnAutofillSupported: mockIsWebAuthnAutofillSupported,
+ __internal_getPublicCredentials: mockWebAuthnGetCredential,
+ } as any;
+
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ client: null,
+ response: {
+ id: 'signin_456',
+ status: 'needs_first_factor',
+ first_factor_verification: {
+ nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }),
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ client: null,
+ response: { id: 'signin_456', status: 'complete' },
+ });
+ BaseResource._fetch = mockFetch;
+
+ const signIn = new SignIn({
+ id: 'signin_123',
+ status: 'needs_second_factor',
+ supported_second_factors: [{ strategy: 'passkey' }],
+ } as any);
+ await signIn.authenticateWithPasskey({ flow });
+
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sign_ins',
+ body: expect.objectContaining({ strategy: 'passkey' }),
+ }),
+ );
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ path: '/client/sign_ins/signin_456/attempt_first_factor',
+ }),
+ );
+ expect(mockWebAuthnGetCredential).toHaveBeenCalledWith(
+ expect.objectContaining({ conditionalUI: flow === 'autofill' }),
+ );
+ },
+ );
+
+ it('falls through to the first-factor flow when the sign-in needs a second factor but does not offer passkey', async () => {
+ const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
+ const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({
+ publicKeyCredential: mockPublicKeyCredential,
+ error: null,
+ });
+
+ SignIn.clerk = {
+ __internal_isWebAuthnSupported: mockIsWebAuthnSupported,
+ __internal_getPublicCredentials: mockWebAuthnGetCredential,
+ } as any;
+
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ client: null,
+ response: {
+ id: 'signin_456',
+ status: 'needs_first_factor',
+ first_factor_verification: {
+ nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }),
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ client: null,
+ response: { id: 'signin_456', status: 'complete' },
+ });
+ BaseResource._fetch = mockFetch;
+
+ // A stale in-progress sign-in parked at needs_second_factor: e.g. the
+ // backend didn't advertise passkey (instance toggle off, no registered
+ // passkey, or an older-client version gate) while the start page still
+ // offers browser-side passkey discovery.
+ const signIn = new SignIn({
+ id: 'signin_123',
+ status: 'needs_second_factor',
+ supported_second_factors: [{ strategy: 'totp' }],
+ } as any);
+ await signIn.authenticateWithPasskey({ flow: 'discoverable' });
+
+ // The in-progress sign-in is discarded: a fresh sign-in is created and
+ // the passkey verifies its first factor, as on clients that predate
+ // passkey second factors.
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sign_ins',
+ body: expect.objectContaining({ strategy: 'passkey' }),
+ }),
+ );
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sign_ins/signin_456/attempt_first_factor',
+ body: expect.objectContaining({
+ strategy: 'passkey',
+ publicKeyCredential: expect.any(String),
+ }),
+ }),
+ );
+ });
+ });
+
describe('trusted_device strategy', () => {
afterEach(() => {
vi.clearAllMocks();
@@ -2462,6 +2671,106 @@ describe('SignIn', () => {
});
});
+ describe('mfa.passkey', () => {
+ const originalFetch = BaseResource._fetch;
+ const originalClerk = SignIn.clerk;
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ BaseResource._fetch = originalFetch;
+ SignIn.clerk = originalClerk;
+ });
+
+ it('prepares and attempts the passkey second factor', async () => {
+ const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
+ const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({
+ publicKeyCredential: {
+ id: 'credential_123',
+ rawId: new ArrayBuffer(32),
+ response: {
+ authenticatorData: new ArrayBuffer(37),
+ clientDataJSON: new ArrayBuffer(121),
+ signature: new ArrayBuffer(64),
+ userHandle: null,
+ },
+ type: 'public-key',
+ },
+ error: null,
+ });
+
+ SignIn.clerk = {
+ __internal_isWebAuthnSupported: mockIsWebAuthnSupported,
+ __internal_getPublicCredentials: mockWebAuthnGetCredential,
+ } as any;
+
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ client: null,
+ response: {
+ id: 'signin_123',
+ status: 'needs_second_factor',
+ supported_second_factors: [{ strategy: 'passkey' }],
+ second_factor_verification: {
+ nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }),
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ client: null,
+ response: { id: 'signin_123', status: 'complete' },
+ });
+ BaseResource._fetch = mockFetch;
+
+ const signIn = new SignIn({
+ id: 'signin_123',
+ status: 'needs_second_factor',
+ supported_second_factors: [{ strategy: 'passkey' }],
+ } as any);
+ await signIn.__internal_future.mfa.passkey();
+
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sign_ins/signin_123/prepare_second_factor',
+ body: { strategy: 'passkey' },
+ }),
+ );
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ method: 'POST',
+ path: '/client/sign_ins/signin_123/attempt_second_factor',
+ body: expect.objectContaining({
+ strategy: 'passkey',
+ publicKeyCredential: expect.any(String),
+ }),
+ }),
+ );
+ });
+
+ it('returns error when passkey second factor is not available', async () => {
+ const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
+
+ SignIn.clerk = {
+ __internal_isWebAuthnSupported: mockIsWebAuthnSupported,
+ } as any;
+
+ const signIn = new SignIn({
+ id: 'signin_123',
+ status: 'needs_second_factor',
+ supported_second_factors: [{ strategy: 'totp' }],
+ } as any);
+
+ const result = await signIn.__internal_future.mfa.passkey();
+
+ expect(result.error).toBeTruthy();
+ expect(result.error?.code).toBe('factor_not_found');
+ });
+ });
+
describe('web3', () => {
afterEach(() => {
vi.clearAllMocks();
diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts
index cc7882eab1a..c707911a13b 100644
--- a/packages/localizations/src/ar-SA.ts
+++ b/packages/localizations/src/ar-SA.ts
@@ -1625,6 +1625,11 @@ export const arSA: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: undefined,
subtitle: undefined,
@@ -1775,6 +1780,10 @@ export const arSA: LocalizationResource = {
'يؤدي استخدام مفتاح المرور الخاص بك إلى تأكيد هويتك. جهازك الخاص قد يقوم بسؤالك عن بصمة الإصبع, او معرف الوجة او كلمة مرور قفل الشاشة',
title: 'إستخدم مفتاح المرور',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'أستعمل طريقة أخرى',
subtitle: 'للمتابعة إلى {{applicationName}}',
diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts
index c95417dc549..db7b9ca5650 100644
--- a/packages/localizations/src/be-BY.ts
+++ b/packages/localizations/src/be-BY.ts
@@ -1632,6 +1632,11 @@ export const beBY: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Вярнуцца да ўводу пароля',
subtitle: 'Калі вы памятаеце свой пароль, вы можаце ўвесці яго для завершэння верыфікацыі.',
@@ -1783,6 +1788,10 @@ export const beBY: LocalizationResource = {
subtitle: 'Выкарыстоўвайце паскей для бяспечнага ўваходу.',
title: 'Увядзіце паскей',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Выкарыстаць іншы метад',
subtitle: 'каб працягнуць працу ў "{{applicationName}}"',
diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts
index a2e222547eb..fc88d5a762e 100644
--- a/packages/localizations/src/bg-BG.ts
+++ b/packages/localizations/src/bg-BG.ts
@@ -1629,6 +1629,11 @@ export const bgBG: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Use another method',
subtitle: 'Enter your current password to continue using "{{applicationName}}"',
@@ -1779,6 +1784,10 @@ export const bgBG: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Използвайте друг метод',
subtitle: 'Въведете паролата, свързана с вашия акаунт',
diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts
index 2cb0e9dec42..7385f976b99 100644
--- a/packages/localizations/src/bn-IN.ts
+++ b/packages/localizations/src/bn-IN.ts
@@ -1637,6 +1637,11 @@ export const bnIN: LocalizationResource = {
'আপনার পাসকি ব্যবহার করে আপনার পরিচয় নিশ্চিত করা হয়। আপনার ডিভাইস আপনার আঙ্গুলের ছাপ, মুখ বা স্ক্রিন লক চাইতে পারে।',
title: 'আপনার পাসকি ব্যবহার করুন',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'অন্য পদ্ধতি ব্যবহার করুন',
subtitle: 'চালিয়ে যেতে আপনার বর্তমান পাসওয়ার্ড লিখুন',
@@ -1788,6 +1793,10 @@ export const bnIN: LocalizationResource = {
'আপনার পাসকি ব্যবহার করলে নিশ্চিত হয় যে এটি আপনি। আপনার ডিভাইস আপনার আঙ্গুলের ছাপ, মুখ বা স্ক্রিন লক চাইতে পারে।',
title: 'আপনার পাসকি ব্যবহার করুন',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'অন্য পদ্ধতি ব্যবহার করুন',
subtitle: 'আপনার অ্যাকাউন্টের সাথে যুক্ত পাসওয়ার্ড লিখুন',
diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts
index fd6720cff57..9225a3c42c5 100644
--- a/packages/localizations/src/ca-ES.ts
+++ b/packages/localizations/src/ca-ES.ts
@@ -1637,6 +1637,11 @@ export const caES: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Has oblidat la contrasenya? Recupera-la aquí.',
subtitle: 'Utilitza la teva contrasenya per verificar la teva identitat.',
@@ -1787,6 +1792,10 @@ export const caES: LocalizationResource = {
subtitle: "Utilitza la teva clau d'accés per continuar amb l'autenticació.",
title: "Clau d'accés",
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utilitza un altre mètode',
subtitle: 'Introdueix la contrasenya associada al teu compte',
diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts
index e025627f5bc..cc16a2a3d70 100644
--- a/packages/localizations/src/cs-CZ.ts
+++ b/packages/localizations/src/cs-CZ.ts
@@ -1635,6 +1635,11 @@ export const csCZ: LocalizationResource = {
'Použití vašeho přístupového klíče potvrzuje vaši identitu. Vaše zařízení může požádat o otisk prstu, obličej nebo zámek obrazovky.',
title: 'Použít váš přístupový klíč',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Použít jinou metodu',
subtitle: 'Zadejte své aktuální heslo pro pokračování',
@@ -1787,6 +1792,10 @@ export const csCZ: LocalizationResource = {
'Použití vašeho přístupového klíče potvrzuje, že jste to vy. Vaše zařízení může požádat o otisk prstu, obličej nebo zámek obrazovky.',
title: 'Použít váš přístupový klíč',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Použít jinou metodu',
subtitle: 'Zadejte heslo spojené s vaším účtem',
diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts
index 6e0d81a20b2..362723d2869 100644
--- a/packages/localizations/src/da-DK.ts
+++ b/packages/localizations/src/da-DK.ts
@@ -1627,6 +1627,11 @@ export const daDK: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: undefined,
subtitle: undefined,
@@ -1777,6 +1782,10 @@ export const daDK: LocalizationResource = {
subtitle: 'Brug adgangsnøgle til at logge ind.',
title: 'Adgangsnøgle',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Brug en anden metode',
subtitle: 'Fortsæt til {{applicationName}}',
diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts
index 4bc781d2353..52ecf5ecb8b 100644
--- a/packages/localizations/src/de-DE.ts
+++ b/packages/localizations/src/de-DE.ts
@@ -1644,6 +1644,11 @@ export const deDE: LocalizationResource = {
'Die Verwendung Ihres Passkeys bestätigt Ihre Identität. Ihr Gerät kann nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen.',
title: 'Verwenden Sie Ihren Passkey',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Passwort zurücksetzen',
subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren.',
@@ -1795,6 +1800,10 @@ export const deDE: LocalizationResource = {
'Die Verwendung Ihres Passkeys bestätigt, dass Sie es sind. Ihr Gerät kann nach Ihrem Fingerabdruck, Ihrem Gesicht oder der Bildschirmsperre fragen.',
title: 'Verwenden Sie Ihren Passkey',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Verwenden Sie eine andere Methode',
subtitle: 'weiter zu {{applicationName}}',
diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts
index 8bf87a2b91f..5013b35ca5b 100644
--- a/packages/localizations/src/el-GR.ts
+++ b/packages/localizations/src/el-GR.ts
@@ -1635,6 +1635,11 @@ export const elGR: LocalizationResource = {
'Η χρήση του passkey σας επαληθεύει ότι είστε εσείς. Η συσκευή σας μπορεί να ζητήσει το δακτυλικό σας αποτύπωμα, την αναγνώριση προσώπου ή το PIN οθόνης.',
title: 'Χρησιμοποιήστε το passkey σας',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Χρησιμοποιήστε άλλη μέθοδο',
subtitle: 'για να συνεχίσετε',
@@ -1788,6 +1793,10 @@ export const elGR: LocalizationResource = {
'Η χρήση του passkey σας επιβεβαιώνει την ταυτότητά σας. Η συσκευή σας μπορεί να ζητήσει δακτυλικό αποτύπωμα, αναγνώριση προσώπου ή κλείδωμα οθόνης.',
title: 'Χρησιμοποιήστε το passkey σας',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Χρήση άλλης μεθόδου',
subtitle: 'για να συνεχίσετε στο {{applicationName}}',
diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts
index 0dfddf53585..a038681c897 100644
--- a/packages/localizations/src/en-GB.ts
+++ b/packages/localizations/src/en-GB.ts
@@ -1628,6 +1628,11 @@ export const enGB: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Use another method',
subtitle: 'Enter your current password to continue',
@@ -1779,6 +1784,10 @@ export const enGB: LocalizationResource = {
subtitle: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.",
title: 'Use your passkey',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Use another method',
subtitle: 'Enter the password associated with your account',
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index 7e862ea758e..b307ffe16d6 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -1675,6 +1675,12 @@ export const enUS: LocalizationResource = {
'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.',
title: 'Use your passkey',
},
+ passkeyMfa: {
+ blockButton__passkey: 'Use your passkey',
+ subtitle:
+ 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.',
+ title: 'Verification required',
+ },
password: {
actionLink: 'Use another method',
subtitle: 'Enter your current password to continue',
@@ -1826,6 +1832,10 @@ export const enUS: LocalizationResource = {
subtitle: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.",
title: 'Use your passkey',
},
+ passkeyMfa: {
+ subtitle: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.",
+ title: 'Use your passkey',
+ },
password: {
actionLink: 'Use another method',
subtitle: 'Enter the password associated with your account',
diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts
index 0db7fbb5555..ed06e9d92f1 100644
--- a/packages/localizations/src/es-CR.ts
+++ b/packages/localizations/src/es-CR.ts
@@ -1633,6 +1633,11 @@ export const esCR: LocalizationResource = {
'Utilizar tu llave de acceso confirma que eres tú. Tu dispositivo puede solicitar tu huella dactilar, rostro o pantalla de bloqueo.',
title: 'Utiliza tu llave de acceso',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utiliza otro método',
subtitle: 'Ingresa tu contraseña actual para continuar',
@@ -1785,6 +1790,10 @@ export const esCR: LocalizationResource = {
'Usando tu llave de acceso confirmas que eres tú. Tu dispositivo puede pedirte la huella dactilar, el rostro o el bloqueo de pantalla.',
title: 'Usa tu llave de acceso',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utiliza otro método',
subtitle: 'para continuar con {{applicationName}}',
diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts
index c8f7d6d7b89..cff65d2b6a5 100644
--- a/packages/localizations/src/es-ES.ts
+++ b/packages/localizations/src/es-ES.ts
@@ -1638,6 +1638,11 @@ export const esES: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '¿Olvidaste tu contraseña? Recupérala aquí.',
subtitle: 'Usa tu contraseña para verificar tu identidad.',
@@ -1788,6 +1793,10 @@ export const esES: LocalizationResource = {
subtitle: 'Use su clave de acceso para continuar con la autenticación.',
title: 'Clave de acceso',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Usa otro método',
subtitle: 'para continuar a {{applicationName}}',
diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts
index 713bc34b6c9..2e6d7e4256d 100644
--- a/packages/localizations/src/es-MX.ts
+++ b/packages/localizations/src/es-MX.ts
@@ -1634,6 +1634,11 @@ export const esMX: LocalizationResource = {
'Utilizar tu llave de acceso confirma que eres tú. Tu dispositivo puede solicitar tu huella dactilar, rostro o pantalla de bloqueo.',
title: 'Utiliza tu llave de acceso',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utiliza otro método',
subtitle: 'Ingresa tu contraseña actual para continuar',
@@ -1786,6 +1791,10 @@ export const esMX: LocalizationResource = {
'Usando tu llave de acceso confirmas que eres tú. Tu dispositivo puede pedirte la huella dactilar, el rostro o el bloqueo de pantalla.',
title: 'Usa tu llave de acceso',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utiliza otro método',
subtitle: 'para continuar con {{applicationName}}',
diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts
index 3e72c1ee734..395d82d04e4 100644
--- a/packages/localizations/src/es-UY.ts
+++ b/packages/localizations/src/es-UY.ts
@@ -1632,6 +1632,11 @@ export const esUY: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Usar otro método',
subtitle: 'Ingresá tu contraseña para continuar',
@@ -1784,6 +1789,10 @@ export const esUY: LocalizationResource = {
'Usar tu clave de acceso confirma que sos vos. Tu dispositivo puede solicitar tu huella, rostro o bloqueo de pantalla.',
title: 'Usar tu clave de acceso',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Usar otro método',
subtitle: 'Ingresá la contraseña asociada a tu cuenta',
diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts
index a255fe5baf8..38ce6f62bba 100644
--- a/packages/localizations/src/fa-IR.ts
+++ b/packages/localizations/src/fa-IR.ts
@@ -1637,6 +1637,11 @@ export const faIR: LocalizationResource = {
'استفاده از کلید عبور، هویت شما را تأیید میکند. ممکن است دستگاه شما از شما اثر انگشت، چهره یا قفل صفحه را درخواست کند.',
title: 'از کلید عبور خود استفاده کنید',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'استفاده از روش دیگر',
subtitle: 'برای ادامه، رمز عبور فعلی خود را وارد کنید',
@@ -1789,6 +1794,10 @@ export const faIR: LocalizationResource = {
'ستفاده از کلید عبور، هویت شما را تأیید میکند. ممکن است دستگاه از شما اثر انگشت، چهره یا قفل صفحه را درخواست کند.',
title: 'از کلید عبور خود استفاده کنید',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'استفاده از روش دیگر',
subtitle: 'رمز عبور مرتبط با حساب کاربری خود را وارد کنید',
diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts
index 7f2d2139608..5e68744f92e 100644
--- a/packages/localizations/src/fi-FI.ts
+++ b/packages/localizations/src/fi-FI.ts
@@ -1639,6 +1639,11 @@ export const fiFI: LocalizationResource = {
'Pääsyavaimen käyttö vahvistaa henkilöllisyytesi. Laitteesi saattaa pyytää sormenjälkeä, kasvoja tai näytön lukitusta.',
title: 'Käytä pääsyavaintasi',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Käytä toista menetelmää',
subtitle: 'Syötä nykyinen salasanasi jatkaaksesi',
@@ -1790,6 +1795,10 @@ export const fiFI: LocalizationResource = {
'Käyttämällä pääsyavaintasi vahvistat, että olet se joka väität olevasi. Laite voi pyytää sormenjälkeäsi, kasvojasi tai näytön lukitusta.',
title: 'Käytä pääsyavaintasi',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Käytä toista tapaa',
subtitle: 'Syötä tilisi salasana',
diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts
index 0c7d92c392e..b10aa5e3a60 100644
--- a/packages/localizations/src/fr-FR.ts
+++ b/packages/localizations/src/fr-FR.ts
@@ -1645,6 +1645,11 @@ export const frFR: LocalizationResource = {
"L'utilisation de votre clé de sécurité confirme votre identité. Votre appareil peut vous demander votre empreinte digitale, votre visage ou votre code de sécurité.",
title: 'Utiliser votre clé de sécurité',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Réinitialiser le mot de passe',
subtitle: 'Entrez votre mot de passe pour continuer.',
@@ -1795,6 +1800,10 @@ export const frFR: LocalizationResource = {
subtitle: 'Utilisez une clé de sécurité pour continuer.',
title: 'Clé de sécurité',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utiliser une autre méthode',
subtitle: 'pour continuer vers {{applicationName}}',
diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts
index a27db252446..76d4e71749c 100644
--- a/packages/localizations/src/he-IL.ts
+++ b/packages/localizations/src/he-IL.ts
@@ -1622,6 +1622,11 @@ export const heIL: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'השתמש בשיטה אחרת',
subtitle: 'הכנס את הסיסמה המקושרת עם חשבונך',
@@ -1770,6 +1775,10 @@ export const heIL: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'השתמש בשיטה אחרת',
subtitle: 'להמשיך אל {{applicationName}}',
diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts
index 78251d7e485..a15280fb922 100644
--- a/packages/localizations/src/hi-IN.ts
+++ b/packages/localizations/src/hi-IN.ts
@@ -1637,6 +1637,11 @@ export const hiIN: LocalizationResource = {
'अपनी पासकी का उपयोग करके आपकी पहचान की पुष्टि होती है। आपका डिवाइस आपके फिंगरप्रिंट, चेहरे या स्क्रीन लॉक के लिए पूछ सकता है।',
title: 'अपनी पासकी का उपयोग करें',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'दूसरी विधि का उपयोग करें',
subtitle: 'जारी रखने के लिए अपना वर्तमान पासवर्ड दर्ज करें',
@@ -1788,6 +1793,10 @@ export const hiIN: LocalizationResource = {
'अपनी पासकी का उपयोग करके पुष्टि होती है कि यह आप ही हैं। आपका डिवाइस आपके फिंगरप्रिंट, चेहरे या स्क्रीन लॉक के लिए पूछ सकता है।',
title: 'अपनी पासकी का उपयोग करें',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'दूसरी विधि का उपयोग करें',
subtitle: 'अपने खाते से जुड़ा पासवर्ड दर्ज करें',
diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts
index d0976af81e5..898e1668daf 100644
--- a/packages/localizations/src/hr-HR.ts
+++ b/packages/localizations/src/hr-HR.ts
@@ -1638,6 +1638,11 @@ export const hrHR: LocalizationResource = {
'Korištenje vašeg pristupnog ključa potvrđuje vaš identitet. Vaš uređaj može tražiti otisak prsta, prepoznavanje lica ili zaključavanje zaslona.',
title: 'Koristite svoj pristupni ključ',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Koristite drugu metodu',
subtitle: 'Unesite svoju trenutnu lozinku za nastavak',
@@ -1790,6 +1795,10 @@ export const hrHR: LocalizationResource = {
'Korištenje vašeg pristupnog ključa potvrđuje da ste to vi. Vaš uređaj može tražiti otisak prsta, prepoznavanje lica ili zaključavanje zaslona.',
title: 'Koristite svoj pristupni ključ',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Koristite drugu metodu',
subtitle: 'Unesite lozinku povezanu s vašim računom',
diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts
index eb5592c2763..ba75bd80414 100644
--- a/packages/localizations/src/hu-HU.ts
+++ b/packages/localizations/src/hu-HU.ts
@@ -1640,6 +1640,11 @@ export const huHU: LocalizationResource = {
'A passkey használata megerősíti a személyazonosságodat. Az eszközöd kérheti az ujjlenyomatodat, arcodat vagy a képernyőzáradat.',
title: 'Passkey használata',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Másik módszer használata',
subtitle: 'Add meg a jelenlegi jelszavadat a folytatáshoz',
@@ -1792,6 +1797,10 @@ export const huHU: LocalizationResource = {
'A Passkey-d használata megerősíti, hogy te vagy az. Az eszközöd kérheti az ujjlenyomatod, arcod vagy a képernyőzárad.',
title: 'Használd a passkeydet',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Másik mód használata',
subtitle: 'Írd be a fiókhoz tartozó jelszavad',
diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts
index e17e483497b..7b35077a8b8 100644
--- a/packages/localizations/src/id-ID.ts
+++ b/packages/localizations/src/id-ID.ts
@@ -1631,6 +1631,11 @@ export const idID: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gunakan metode lain',
subtitle: 'Masukkan kata sandi Anda untuk melanjutkan',
@@ -1783,6 +1788,10 @@ export const idID: LocalizationResource = {
'Menggunakan passkey mengonfirmasi bahwa ini adalah Anda. Perangkat Anda mungkin meminta sidik jari, wajah, atau kunci layar.',
title: 'Gunakan passkey Anda',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gunakan metode lain',
subtitle: 'Masukkan kata sandi yang terkait dengan akun Anda',
diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts
index f468b8838d9..f015eed4f00 100644
--- a/packages/localizations/src/is-IS.ts
+++ b/packages/localizations/src/is-IS.ts
@@ -1639,6 +1639,11 @@ export const isIS: LocalizationResource = {
'Að nota lykilinn þinn staðfestir auðkenni þitt. Tækið þitt gæti beðið um fingrafar, andlit eða skjálás.',
title: 'Nota lykilinn þinn',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Nota aðra aðferð',
subtitle: 'Sláðu inn núverandi lykilorð til að halda áfram',
@@ -1791,6 +1796,10 @@ export const isIS: LocalizationResource = {
'Að nota lykilinn þinn staðfestir að þú ert það. Tækið þitt gæti beðið um fingrafar, andlit eða skjálás.',
title: 'Nota lykilinn þinn',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Nota aðra aðferð',
subtitle: 'Sláðu inn lykilorðið sem tengist reikningnum þínum',
diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts
index ac1f77d29c5..4044db1665a 100644
--- a/packages/localizations/src/it-IT.ts
+++ b/packages/localizations/src/it-IT.ts
@@ -1637,6 +1637,11 @@ export const itIT: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Reimposta la password',
subtitle: 'Inserisci la tua password per continuare.',
@@ -1787,6 +1792,10 @@ export const itIT: LocalizationResource = {
subtitle: 'Usa una passkey per un accesso più sicuro e rapido.',
title: 'Autenticazione tramite passkey',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Usa un altro metodo',
subtitle: 'per continuare su {{applicationName}}',
diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts
index 1fcea5854dc..e0298371964 100644
--- a/packages/localizations/src/ja-JP.ts
+++ b/packages/localizations/src/ja-JP.ts
@@ -1638,6 +1638,11 @@ export const jaJP: LocalizationResource = {
'パスキーを使用すると、ご本人であることを確認できます。デバイスから指紋認証、顔認証、または画面ロックの解除を求められる場合があります。',
title: 'パスキーを使用する',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '別の方法を使用',
subtitle: '続行するには現在のパスワードを入力してください',
@@ -1789,6 +1794,10 @@ export const jaJP: LocalizationResource = {
'パスキーを使用すると、ご本人であることを確認できます。デバイスから指紋認証、顔認証、または画面ロックの解除を求められる場合があります。',
title: 'パスキーを使用する',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '別の方法を使用',
subtitle: 'アカウントに関連付けられたパスワードを入力してください',
diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts
index 70735f967e9..8a51cb18eca 100644
--- a/packages/localizations/src/kk-KZ.ts
+++ b/packages/localizations/src/kk-KZ.ts
@@ -1621,6 +1621,11 @@ export const kkKZ: LocalizationResource = {
'Құпия кілт арқылы сіздің тұлғаңыз расталады. Құрылғыңыз саусақ ізі, бет бейнесі немесе экран құлыптамасын сұрауы мүмкін.',
title: 'Құпия кілт қолдану',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Басқа әдісті қолдану',
subtitle: 'Жалғастыру үшін құпия сөзді енгізіңіз',
@@ -1771,6 +1776,10 @@ export const kkKZ: LocalizationResource = {
'Құпия кілт арқылы тұлғаңыз расталады. Құрылғыңыз саусақ ізі, бет бейнесі немесе экран құлыптамасын сұрауы мүмкін.',
title: 'Құпия кілт қолдану',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Басқа әдісті қолдану',
subtitle: 'Есептік жазбаңыздың құпия сөзін енгізіңіз',
diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts
index 78896999f98..abec99a1dff 100644
--- a/packages/localizations/src/ko-KR.ts
+++ b/packages/localizations/src/ko-KR.ts
@@ -1626,6 +1626,11 @@ export const koKR: LocalizationResource = {
subtitle: '패스키로 신원을 확인해요. 기기에서 지문, 얼굴 또는 화면 잠금을 요청할 수 있어요.',
title: '패스키 사용',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '다른 방법 사용하기',
subtitle: '계속하려면 현재 비밀번호를 입력하세요',
@@ -1774,6 +1779,10 @@ export const koKR: LocalizationResource = {
subtitle: '패스키로 로그인하면 본인 확인이 돼요. 기기에서 지문, 얼굴 또는 화면 잠금을 요청할 수 있어요.',
title: '패스키 사용',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '다른 방법 사용하기',
subtitle: '계정에 등록된 비밀번호를 입력해 주세요',
diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts
index c1e2bed88b7..64c73cef2a1 100644
--- a/packages/localizations/src/mn-MN.ts
+++ b/packages/localizations/src/mn-MN.ts
@@ -1630,6 +1630,11 @@ export const mnMN: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: undefined,
subtitle: undefined,
@@ -1781,6 +1786,10 @@ export const mnMN: LocalizationResource = {
'Passkey-ээ ашигласнаар таныг мөн болохыг баталгаажуулна. Таны төхөөрөмж хурууны хээ, нүүр эсвэл дэлгэцийн түгжээг асууж магадгүй.',
title: 'Passkey ашиглана уу',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Өөр аргыг ашигла',
subtitle: 'Бүртгэлтэй холбоотой нууц үгээ оруулна уу',
diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts
index 016bc8c0743..99c926f3155 100644
--- a/packages/localizations/src/ms-MY.ts
+++ b/packages/localizations/src/ms-MY.ts
@@ -1641,6 +1641,11 @@ export const msMY: LocalizationResource = {
'Menggunakan kunci pas anda mengesahkan identiti anda. Peranti anda mungkin meminta cap jari, wajah, atau kunci skrin anda.',
title: 'Gunakan kunci pas anda',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gunakan kaedah lain',
subtitle: 'Masukkan kata laluan semasa anda untuk meneruskan',
@@ -1793,6 +1798,10 @@ export const msMY: LocalizationResource = {
'Menggunakan kunci pas anda mengesahkan bahawa itu adalah anda. Peranti anda mungkin meminta cap jari, wajah atau kunci skrin anda.',
title: 'Gunakan kunci pas anda',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gunakan kaedah lain',
subtitle: 'Masukkan kata laluan yang berkaitan dengan akaun anda',
diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts
index 384b98fdd7b..0ebbf9b3315 100644
--- a/packages/localizations/src/nb-NO.ts
+++ b/packages/localizations/src/nb-NO.ts
@@ -1640,6 +1640,11 @@ export const nbNO: LocalizationResource = {
'Bruk av passnøkkelen bekrefter identiteten din. Enheten din kan be om fingeravtrykk, ansiktsgjenkjenning eller skjermlås.',
title: 'Bruk passnøkkelen din',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Bruk en annen metode',
subtitle: 'Skriv inn ditt nåværende passord for å fortsette',
@@ -1791,6 +1796,10 @@ export const nbNO: LocalizationResource = {
'Bruk av passnøkkelen bekrefter at det er deg. Enheten din kan be om fingeravtrykk, ansiktsgjenkjenning eller skjermlås.',
title: 'Bruk passnøkkelen din',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Bruk en annen metode',
subtitle: 'for å fortsette til {{applicationName}}',
diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts
index 1bf581ef397..8ceaa13c70a 100644
--- a/packages/localizations/src/nl-BE.ts
+++ b/packages/localizations/src/nl-BE.ts
@@ -1631,6 +1631,11 @@ export const nlBE: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gebruik een andere methode',
subtitle: 'Voer het wachtwoord in dat bij je account hoort',
@@ -1780,6 +1785,10 @@ export const nlBE: LocalizationResource = {
subtitle: 'Gebruik je toegangssleutel voor authenticatie.',
title: 'Authenticatie met toegangssleutel',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gebruik een andere methode',
subtitle: 'om door te gaan naar {{applicationName}}',
diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts
index 2349d69abe6..762a4f68899 100644
--- a/packages/localizations/src/nl-NL.ts
+++ b/packages/localizations/src/nl-NL.ts
@@ -1631,6 +1631,11 @@ export const nlNL: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gebruik een andere methode',
subtitle: 'Voer het wachtwoord in dat bij je account hoort',
@@ -1780,6 +1785,10 @@ export const nlNL: LocalizationResource = {
subtitle: 'Gebruik je toegangssleutel voor authenticatie.',
title: 'Authenticatie met toegangssleutel',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Gebruik een andere methode',
subtitle: 'om door te gaan naar {{applicationName}}',
diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts
index c6f75646e70..19eed5d20d3 100644
--- a/packages/localizations/src/pl-PL.ts
+++ b/packages/localizations/src/pl-PL.ts
@@ -1629,6 +1629,11 @@ export const plPL: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Użyj innej metody',
subtitle: 'Wprowadź hasło, aby kontynuować',
@@ -1781,6 +1786,10 @@ export const plPL: LocalizationResource = {
'Użycie klucza dostępu potwierdza, że to Ty. Urządzenie może poprosić o twój odcisk palca, twarz lub blokadę ekranu.',
title: 'Użyj swojego klucza dostępowego',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Użyj innego sposobu',
subtitle: 'aby kontynuować w {{applicationName}}',
diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts
index 7138b2f1e6f..0b265682603 100644
--- a/packages/localizations/src/pt-BR.ts
+++ b/packages/localizations/src/pt-BR.ts
@@ -1639,6 +1639,11 @@ export const ptBR: LocalizationResource = {
'Usar sua chave de acesso confirma a sua identidade. Seu dispositivo pode solicitar sua impressão digital, reconhecimento facial ou PIN.',
title: 'Use sua chave de acesso.',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Usar outro método',
subtitle: 'Digite sua senha para continuar.',
@@ -1790,6 +1795,10 @@ export const ptBR: LocalizationResource = {
'Usar sua chave de acesso confirma a sua identidade. Seu dispositivo pode solicitar sua impressão digital, reconhecimento facial ou PIN.',
title: 'Use sua chave de acesso.',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utilize outro método',
subtitle: 'para continuar em {{applicationName}}',
diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts
index f0659aa1e63..72e81ceabb0 100644
--- a/packages/localizations/src/pt-PT.ts
+++ b/packages/localizations/src/pt-PT.ts
@@ -1640,6 +1640,11 @@ export const ptPT: LocalizationResource = {
'A utilização da sua chave de acesso confirma a sua identidade. O dispositivo pode pedir a sua impressão digital, rosto ou bloqueio de ecrã.',
title: 'Utilizar a sua chave de acesso',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utilizar outro método',
subtitle: 'Introduza a sua palavra-passe atual para continuar',
@@ -1790,6 +1795,10 @@ export const ptPT: LocalizationResource = {
subtitle: 'Utilize a sua chave de acesso para autenticação.',
title: 'Autenticação com Chave de Acesso',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Utilize outro método',
subtitle: 'para continuar em {{applicationName}}',
diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts
index e9000c9055a..d8515f1b2ae 100644
--- a/packages/localizations/src/ro-RO.ts
+++ b/packages/localizations/src/ro-RO.ts
@@ -1640,6 +1640,11 @@ export const roRO: LocalizationResource = {
'Folosirea cheii de acces confirmă identitatea ta. Dispozitivul îți poate cere amprenta, fața sau codul de ecran.',
title: 'Folosește cheia de acces',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Folosește altă metodă',
subtitle: 'Introdu parola curentă pentru a continua',
@@ -1792,6 +1797,10 @@ export const roRO: LocalizationResource = {
'Folosirea cheii de acces confirmă că ești tu. Dispozitivul îți poate cere amprenta, fața sau blocarea ecranului.',
title: 'Folosește cheia de acces',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Folosește altă metodă',
subtitle: 'Introdu parola asociată contului tău',
diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts
index 73ba47d5a48..3af75fb03eb 100644
--- a/packages/localizations/src/ru-RU.ts
+++ b/packages/localizations/src/ru-RU.ts
@@ -1636,6 +1636,11 @@ export const ruRU: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Используйте другой метод',
subtitle: 'Введите пароль, связанный с вашей учетной записью',
@@ -1788,6 +1793,10 @@ export const ruRU: LocalizationResource = {
'Использование вашего ключа доступа подтверждает вашу личность. Ваше устройство может запросить ваш отпечаток пальца, лицо или блокировку экрана.',
title: 'Используйте ваш ключ доступа',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Использовать другой метод',
subtitle: 'чтобы продолжить работу в "{{applicationName}}"',
diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts
index 025e874ff30..1dcc2f679d6 100644
--- a/packages/localizations/src/sk-SK.ts
+++ b/packages/localizations/src/sk-SK.ts
@@ -1630,6 +1630,11 @@ export const skSK: LocalizationResource = {
'Použitie passkey potvrdí vašu identitu. Vaše zariadenie vás môže vyzvať na potvrdenie odtlačkom, tvárou alebo kódom.',
title: 'Použiť Passkey',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Použiť inú metódu',
subtitle: 'Pre pokračovanie vložte svoje aktuálne heslo',
@@ -1781,6 +1786,10 @@ export const skSK: LocalizationResource = {
'Použitie passkey potvrdí vašu identitu. Vaše zariadenie vás môže vyzvať na potvrdenie odtlačkom, tvárou alebo kódom.',
title: 'Prihlásiť sa pomocou Passkey',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Použiť inú metódu',
subtitle: 'pre pokračovanie do {{applicationName}}',
diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts
index 14e8a094adf..77c45b2f883 100644
--- a/packages/localizations/src/sr-RS.ts
+++ b/packages/localizations/src/sr-RS.ts
@@ -1627,6 +1627,11 @@ export const srRS: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: undefined,
subtitle: undefined,
@@ -1778,6 +1783,10 @@ export const srRS: LocalizationResource = {
'Korišćenje tvojeg ključa za prolaz potvrđuje da si to ti. Tvoj uređaj može zatražiti otisak prsta, lice ili ekran zaključavanja.',
title: 'Koristi svoj ključ za prolaz',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Koristi drugu metodu',
subtitle: 'Unesi lozinku koja je povezana sa tvojim nalogom',
diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts
index f411009e4f3..cec06c14be5 100644
--- a/packages/localizations/src/sv-SE.ts
+++ b/packages/localizations/src/sv-SE.ts
@@ -1629,6 +1629,11 @@ export const svSE: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Använd en annan metod',
subtitle: 'Ange lösenordet som är kopplat till ditt konto',
@@ -1781,6 +1786,10 @@ export const svSE: LocalizationResource = {
'Att använda din passkey bekräftar att det är du. Din enhet kan be om ditt fingeravtryck, ansikte eller skärmlås.',
title: 'Använd din passkey',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Använd en annan metod',
subtitle: 'för att fortsätta till {{applicationName}}',
diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts
index 076f86f9873..5dbb4287d9c 100644
--- a/packages/localizations/src/ta-IN.ts
+++ b/packages/localizations/src/ta-IN.ts
@@ -1643,6 +1643,11 @@ export const taIN: LocalizationResource = {
'உங்கள் பாஸ்கீயைப் பயன்படுத்துவது உங்கள் அடையாளத்தை உறுதிப்படுத்துகிறது. உங்கள் சாதனம் உங்கள் கைரேகை, முகம் அல்லது திரை பூட்டைக் கேட்கலாம்.',
title: 'உங்கள் பாஸ்கீயைப் பயன்படுத்தவும்',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'வேறு முறையைப் பயன்படுத்துங்கள்',
subtitle: 'தொடர உங்கள் தற்போதைய கடவுச்சொல்லை உள்ளிடவும்',
@@ -1794,6 +1799,10 @@ export const taIN: LocalizationResource = {
'உங்கள் பாஸ்கீயைப் பயன்படுத்துவது நீங்கள் தான் என்பதை உறுதிப்படுத்துகிறது. உங்கள் சாதனம் உங்கள் கைரேகை, முகம் அல்லது திரை பூட்டைக் கேட்கலாம்.',
title: 'உங்கள் பாஸ்கீயைப் பயன்படுத்தவும்',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'வேறு முறையைப் பயன்படுத்துங்கள்',
subtitle: 'உங்கள் கணக்குடன் தொடர்புடைய கடவுச்சொல்லை உள்ளிடவும்',
diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts
index 8a4c8d6f7a2..341a072a8c1 100644
--- a/packages/localizations/src/te-IN.ts
+++ b/packages/localizations/src/te-IN.ts
@@ -1640,6 +1640,11 @@ export const teIN: LocalizationResource = {
'మీ పాస్కీని ఉపయోగించడం మీ గుర్తింపును నిర్ధారిస్తుంది. మీ పరికరం మీ వేలిముద్ర, ముఖం లేదా స్క్రీన్ లాక్ కోసం అడగవచ్చు.',
title: 'మీ పాస్కీని ఉపయోగించండి',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'మరొక పద్ధతిని ఉపయోగించండి',
subtitle: 'కొనసాగించడానికి మీ ప్రస్తుత పాస్వర్డ్ను నమోదు చేయండి',
@@ -1791,6 +1796,10 @@ export const teIN: LocalizationResource = {
'మీ పాస్కీని ఉపయోగించడం అది మీరని నిర్ధారిస్తుంది. మీ పరికరం మీ వేలిముద్ర, ముఖం లేదా స్క్రీన్ లాక్ కోసం అడగవచ్చు.',
title: 'మీ పాస్కీని ఉపయోగించండి',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'మరొక పద్ధతిని ఉపయోగించండి',
subtitle: 'మీ ఖాతాతో సంబంధం ఉన్న పాస్వర్డ్ను నమోదు చేయండి',
diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts
index eebb4436919..51dcfa5b0e9 100644
--- a/packages/localizations/src/th-TH.ts
+++ b/packages/localizations/src/th-TH.ts
@@ -1629,6 +1629,11 @@ export const thTH: LocalizationResource = {
subtitle: 'การใช้พาสคีย์ของคุณยืนยันตัวตนของคุณ อุปกรณ์ของคุณอาจขอลายนิ้วมือ ใบหน้า หรือการล็อคหน้าจอ',
title: 'ใช้พาสคีย์ของคุณ',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'ใช้วิธีอื่น',
subtitle: 'ใส่รหัสผ่านปัจจุบันของคุณเพื่อดำเนินการต่อ',
@@ -1779,6 +1784,10 @@ export const thTH: LocalizationResource = {
subtitle: 'การใช้พาสคีย์ของคุณยืนยันว่าเป็นคุณ อุปกรณ์ของคุณอาจขอลายนิ้วมือ ใบหน้า หรือการล็อคหน้าจอ',
title: 'ใช้พาสคีย์ของคุณ',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'ใช้วิธีอื่น',
subtitle: 'ใส่รหัสผ่านที่เชื่อมโยงกับบัญชีของคุณ',
diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts
index e840c7c2b2c..50d01c57240 100644
--- a/packages/localizations/src/tr-TR.ts
+++ b/packages/localizations/src/tr-TR.ts
@@ -1628,6 +1628,11 @@ export const trTR: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Şifremi unuttum',
subtitle: 'Şifrenizi girerek devam edebilirsiniz.',
@@ -1780,6 +1785,10 @@ export const trTR: LocalizationResource = {
'Geçiş anahtarınızı kullanarak siz olduğunuzu onaylayın. Cihazınız parmak izinizi, yüzünüzü veya ekran kilidinizi isteyebilir.',
title: 'Geçiş anahtarınızı kullanın',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Başka bir yöntem kullan',
subtitle: '{{applicationName}} ile devam etmek için',
diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts
index bd77cc5dcd0..9125e2b878c 100644
--- a/packages/localizations/src/uk-UA.ts
+++ b/packages/localizations/src/uk-UA.ts
@@ -1627,6 +1627,11 @@ export const ukUA: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: undefined,
subtitle: undefined,
@@ -1777,6 +1782,10 @@ export const ukUA: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Використати інший метод',
subtitle: 'щоб продовжити роботу в "{{applicationName}}"',
diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts
index 35e6662dc65..1b5a87abe44 100644
--- a/packages/localizations/src/vi-VN.ts
+++ b/packages/localizations/src/vi-VN.ts
@@ -1637,6 +1637,11 @@ export const viVN: LocalizationResource = {
'Sử dụng mã passkey xác minh danh tính của bạn. Thiết bị của bạn có thể yêu cầu vân tay, khuôn mặt hoặc khóa màn hình.',
title: 'Sử dụng mã passkey',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Sử dụng phương thức khác',
subtitle: 'Nhập mật khẩu hiện tại để tiếp tục',
@@ -1788,6 +1793,10 @@ export const viVN: LocalizationResource = {
'Sử dụng mã passkey xác minh danh tính của bạn. Thiết bị của bạn có thể yêu cầu vân tay, khuôn mặt hoặc khóa màn hình.',
title: 'Sử dụng mã passkey',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: 'Sử dụng phương thức khác',
subtitle: 'Nhập mật khẩu được liên kết với tài khoản của bạn',
diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts
index 7ea16a28b01..3e65172457b 100644
--- a/packages/localizations/src/zh-CN.ts
+++ b/packages/localizations/src/zh-CN.ts
@@ -1618,6 +1618,11 @@ export const zhCN: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: undefined,
subtitle: undefined,
@@ -1766,6 +1771,10 @@ export const zhCN: LocalizationResource = {
subtitle: undefined,
title: undefined,
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '使用其他方法',
subtitle: '继续使用 {{applicationName}}',
diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts
index ee115cb297f..3d905a96077 100644
--- a/packages/localizations/src/zh-TW.ts
+++ b/packages/localizations/src/zh-TW.ts
@@ -1621,6 +1621,11 @@ export const zhTW: LocalizationResource = {
subtitle: '使用您的金鑰確認您的身分。您的裝置可能會要求您的指紋、臉部或螢幕鎖。',
title: '使用您的金鑰',
},
+ passkeyMfa: {
+ blockButton__passkey: undefined,
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '使用其他方式',
subtitle: '請輸入您的密碼以繼續',
@@ -1769,6 +1774,10 @@ export const zhTW: LocalizationResource = {
subtitle: '使用您的金鑰確認您的身分。您的裝置可能會要求您的指紋、臉部或螢幕鎖。',
title: '使用您的金鑰',
},
+ passkeyMfa: {
+ subtitle: undefined,
+ title: undefined,
+ },
password: {
actionLink: '使用其他方式',
subtitle: '以繼續前往 {{applicationName}}',
diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts
index d8bb23c6423..e9447eb3d75 100644
--- a/packages/react/src/stateProxy.ts
+++ b/packages/react/src/stateProxy.ts
@@ -252,6 +252,7 @@ export class StateProxy implements State {
'verifyEmailCode',
'verifyTOTP',
'verifyBackupCode',
+ 'passkey',
] as const),
ticket: this.gateMethod(target, 'ticket'),
passkey: this.gateMethod(target, 'passkey'),
diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts
index b87fbaa578f..8752e064a5b 100644
--- a/packages/shared/src/internal/clerk-js/constants.ts
+++ b/packages/shared/src/internal/clerk-js/constants.ts
@@ -67,7 +67,7 @@ export const SIGN_UP_MODES = {
} satisfies Record;
// This is the currently supported version of the Frontend API
-export const SUPPORTED_FAPI_VERSION = '2026-05-12';
+export const SUPPORTED_FAPI_VERSION = '2026-08-20';
export const CAPTCHA_ELEMENT_ID = 'clerk-captcha';
export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha';
diff --git a/packages/shared/src/internal/clerk-js/errors.ts b/packages/shared/src/internal/clerk-js/errors.ts
index 77345b56d3e..8242f06d8bb 100644
--- a/packages/shared/src/internal/clerk-js/errors.ts
+++ b/packages/shared/src/internal/clerk-js/errors.ts
@@ -102,6 +102,15 @@ export function clerkInvalidStrategy(functionaName: string, strategy: string): n
throw new Error(`${errorPrefix} Strategy "${strategy}" is not a valid strategy for ${functionaName}.`);
}
+/**
+ *
+ */
+export function clerkInvalidVerificationLevel(functionName: string, level: string): never {
+ throw new Error(
+ `${errorPrefix} "${level}" is not a valid verification level for ${functionName}. Use 'first_factor' or 'second_factor'.`,
+ );
+}
+
/**
*
*/
diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts
index 78e90a86b4f..446d328a8e5 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -588,6 +588,10 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
subtitle: LocalizationValue;
};
+ passkeyMfa: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ };
alternativeMethods: {
title: LocalizationValue;
subtitle: LocalizationValue;
@@ -682,6 +686,11 @@ export type __internal_LocalizationResource = {
subtitle: LocalizationValue;
blockButton__passkey: LocalizationValue;
};
+ passkeyMfa: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ blockButton__passkey: LocalizationValue;
+ };
alternativeMethods: {
title: LocalizationValue;
subtitle: LocalizationValue;
diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts
index a492a586be0..77362fb96d2 100644
--- a/packages/shared/src/types/session.ts
+++ b/packages/shared/src/types/session.ts
@@ -345,9 +345,15 @@ export interface SessionResource extends ClerkResource {
) => Promise;
/**
* Initiates a verification flow using passkeys.
+ *
+ * By default the passkey verifies the first factor. Pass
+ * `{ level: 'second_factor' }` to satisfy the second factor of an
+ * in-progress multi-factor reverification instead. Any other `level`
+ * value is rejected. Throws a `ClerkWebAuthnError` when WebAuthn is
+ * unsupported or the passkey ceremony fails.
* @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors.
*/
- verifyWithPasskey: () => Promise;
+ verifyWithPasskey: (params?: SessionVerifyWithPasskeyParams) => Promise;
__internal_toSnapshot: () => SessionJSONSnapshot;
__internal_touch: (params?: SessionTouchParams) => Promise;
}
@@ -552,6 +558,7 @@ export type SessionVerifyAttemptFirstFactorParams =
export type SessionVerifyPrepareSecondFactorParams =
| PhoneCodeSecondFactorConfig
+ | PassKeyConfig
/**
* @experimental
*/
@@ -560,7 +567,17 @@ export type SessionVerifyAttemptSecondFactorParams =
| PhoneCodeAttempt
| TOTPAttempt
| BackupCodeAttempt
+ | PasskeyAttempt
/**
* @experimental
*/
| TrustedDeviceAttempt;
+
+export type SessionVerifyWithPasskeyParams = {
+ /**
+ * Which factor of the reverification the passkey should verify. Defaults
+ * to `'first_factor'`; pass `'second_factor'` to satisfy the second
+ * factor of an in-progress multi-factor reverification.
+ */
+ level?: 'first_factor' | 'second_factor';
+};
diff --git a/packages/shared/src/types/sessionVerification.ts b/packages/shared/src/types/sessionVerification.ts
index 61af637ce2b..bcbd347d5fd 100644
--- a/packages/shared/src/types/sessionVerification.ts
+++ b/packages/shared/src/types/sessionVerification.ts
@@ -59,4 +59,4 @@ export type SessionVerificationFirstFactor =
* @experimental
*/
| EnterpriseSSOFactor;
-export type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor;
+export type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor | PasskeyFactor;
diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts
index 922686321bc..46c8f8133ab 100644
--- a/packages/shared/src/types/signInCommon.ts
+++ b/packages/shared/src/types/signInCommon.ts
@@ -86,7 +86,13 @@ export type SignInFirstFactor =
| OauthFactor
| EnterpriseSSOFactor;
-export type SignInSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor | EmailCodeFactor | EmailLinkFactor;
+export type SignInSecondFactor =
+ | PhoneCodeFactor
+ | TOTPFactor
+ | BackupCodeFactor
+ | EmailCodeFactor
+ | EmailLinkFactor
+ | PasskeyFactor;
export interface UserData {
firstName?: string;
@@ -118,9 +124,18 @@ export type AttemptFirstFactorParams =
| ResetPasswordEmailCodeAttempt
| TrustedDeviceAttempt;
-export type PrepareSecondFactorParams = PhoneCodeSecondFactorConfig | EmailCodeSecondFactorConfig | EmailLinkConfig;
+export type PrepareSecondFactorParams =
+ | PhoneCodeSecondFactorConfig
+ | EmailCodeSecondFactorConfig
+ | EmailLinkConfig
+ | PassKeyConfig;
-export type AttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt | EmailCodeAttempt;
+export type AttemptSecondFactorParams =
+ | PhoneCodeAttempt
+ | TOTPAttempt
+ | BackupCodeAttempt
+ | EmailCodeAttempt
+ | PasskeyAttempt;
export type SignInCreateParams = (
| {
@@ -191,6 +206,16 @@ export type ResetPasswordParams = {
};
export type AuthenticateWithPasskeyParams = {
+ /**
+ * The passkey flow to use when the passkey is the FIRST factor:
+ * `'autofill'` (conditional UI) or `'discoverable'` both create a new
+ * sign-in and identify the user from the passkey itself, discarding any
+ * in-progress sign-in.
+ *
+ * Omit it to complete the second factor of a sign-in in the
+ * `needs_second_factor` (or `needs_client_trust`) status that offers
+ * `passkey` among its supported second factors.
+ */
flow?: 'autofill' | 'discoverable';
};
diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts
index fe1ff3d48eb..65c2d6354d5 100644
--- a/packages/shared/src/types/signInFuture.ts
+++ b/packages/shared/src/types/signInFuture.ts
@@ -362,7 +362,7 @@ export interface SignInFutureResource {
*
`'needs_client_trust'` - The user is signing in from a new device and must complete a [second factor verification](!second-factor-verification) to establish [Device Trust](https://clerk.com/docs/guides/secure/device-trust). See the [Device Trust custom flow guide](https://clerk.com/docs/guides/development/custom-flows/authentication/device-trust) for more information.
*
`'needs_identifier'` - The user's identifier (e.g., email address, phone number, username) hasn't been provided.
*
`'needs_first_factor'` - One of the following [first factor verification](!first-factor-verification) strategies is missing: `'email_link'`, `'email_code'`, `passkey`, `password`, `'phone_code'`, `'web3_base_signature'`, `'web3_metamask_signature'`, `'web3_coinbase_wallet_signature'`, `'web3_okx_wallet_signature'`, `'web3_solana_signature'`, [`OAuthStrategy`](https://clerk.com/docs/reference/types/sso#oauthstrategy), or `'enterprise_sso'`.
- *
`'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, or `'email_link'`.
+ *
`'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, `'email_link'`, or `'passkey'`.
*
`'needs_new_password'` - The user needs to set a new password. See the [dedicated custom flow](/docs/guides/development/custom-flows/authentication/forgot-password) guide for more information.
*
`'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they return. Otherwise, run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. When `sso()` redirects to the identity provider, the challenge runs on the way back through `` instead. The pre-built components handle this automatically.
*
@@ -585,6 +585,14 @@ export interface SignInFutureResource {
* Verifies a backup code to sign in with as a second factor.
*/
verifyBackupCode: (params: SignInFutureBackupCodeVerifyParams) => Promise<{ error: ClerkError | null }>;
+
+ /**
+ * Uses a passkey to sign in with as a second factor. Only available when
+ * the instance allows passkeys to satisfy the second factor and the user
+ * has a registered passkey (`passkey` appears in
+ * `supportedSecondFactors`).
+ */
+ passkey: () => Promise<{ error: ClerkError | null }>;
};
/**
diff --git a/packages/ui/src/components/SignIn/SignInClientTrust.tsx b/packages/ui/src/components/SignIn/SignInClientTrust.tsx
index aa14fd77fb1..6a44b0d46de 100644
--- a/packages/ui/src/components/SignIn/SignInClientTrust.tsx
+++ b/packages/ui/src/components/SignIn/SignInClientTrust.tsx
@@ -3,11 +3,14 @@ import { LoadingCard } from '@/ui/elements/LoadingCard';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
import { useCoreSignIn } from '../../contexts';
+import { useIsWebAuthnSupported } from '../../hooks';
import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods';
import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard';
import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard';
+import { SignInFactorTwoPasskeyCard } from './SignInFactorTwoPasskeyCard';
import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard';
import { useSecondFactorSelection } from './useSecondFactorSelection';
+import { isOfferableSecondFactor } from './utils';
function SignInClientTrustInternal(): JSX.Element {
const signIn = useCoreSignIn();
@@ -19,8 +22,11 @@ function SignInClientTrustInternal(): JSX.Element {
showAllStrategies,
toggleAllStrategies,
} = useSecondFactorSelection(signIn.supportedSecondFactors);
+ const webAuthnSupported = useIsWebAuthnSupported();
const onShowAlternativeMethodsClicked =
- signIn.supportedSecondFactors && signIn.supportedSecondFactors.length > 1 ? toggleAllStrategies : undefined;
+ (signIn.supportedSecondFactors?.filter(f => isOfferableSecondFactor(f, webAuthnSupported)).length ?? 0) > 1
+ ? toggleAllStrategies
+ : undefined;
if (!currentFactor) {
return ;
@@ -66,6 +72,8 @@ function SignInClientTrustInternal(): JSX.Element {
onShowAlternativeMethodsClicked={onShowAlternativeMethodsClicked}
/>
);
+ case 'passkey':
+ return ;
default:
return ;
}
diff --git a/packages/ui/src/components/SignIn/SignInFactorOne.tsx b/packages/ui/src/components/SignIn/SignInFactorOne.tsx
index a2dd6ee994b..a6d25b6f7d3 100644
--- a/packages/ui/src/components/SignIn/SignInFactorOne.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorOne.tsx
@@ -8,6 +8,7 @@ import { LoadingCard } from '@/ui/elements/LoadingCard';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
import { useCoreSignIn, useEnvironment } from '../../contexts';
+import { useIsWebAuthnSupported } from '../../hooks';
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
import { localizationKeys } from '../../localization';
import { useRouter } from '../../router';
@@ -79,6 +80,7 @@ function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
const { preferredSignInStrategy } = useEnvironment().displayConfig;
+ const webAuthnSupported = useIsWebAuthnSupported();
const availableFactors = signIn.supportedFirstFactors;
const router = useRouter();
const card = useCardState();
@@ -89,7 +91,12 @@ function SignInFactorOneInternal(): JSX.Element {
currentFactor: SignInFactor | undefined | null;
prevCurrentFactor: SignInFactor | undefined | null;
}>(() => {
- const factor = determineStartingSignInFactor(availableFactors, signIn.identifier, preferredSignInStrategy);
+ const factor = determineStartingSignInFactor(
+ availableFactors,
+ signIn.identifier,
+ preferredSignInStrategy,
+ webAuthnSupported,
+ );
if (
factor?.strategy === 'phone_code' &&
!!firstFactorVerification.channel &&
diff --git a/packages/ui/src/components/SignIn/SignInFactorTwo.tsx b/packages/ui/src/components/SignIn/SignInFactorTwo.tsx
index 1af218d771c..7d5efe22944 100644
--- a/packages/ui/src/components/SignIn/SignInFactorTwo.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorTwo.tsx
@@ -6,14 +6,17 @@ import { LoadingCard } from '@/ui/elements/LoadingCard';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
import { useCoreSignIn, useSignInContext } from '../../contexts';
+import { useIsWebAuthnSupported } from '../../hooks';
import { useRouter } from '../../router';
import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods';
import { SignInFactorTwoBackupCodeCard } from './SignInFactorTwoBackupCodeCard';
import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard';
import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard';
+import { SignInFactorTwoPasskeyCard } from './SignInFactorTwoPasskeyCard';
import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard';
import { SignInFactorTwoTOTPCard } from './SignInFactorTwoTOTPCard';
import { useSecondFactorSelection } from './useSecondFactorSelection';
+import { isOfferableSecondFactor } from './utils';
function SignInFactorTwoInternal(): JSX.Element {
const clerk = useClerk();
@@ -28,8 +31,11 @@ function SignInFactorTwoInternal(): JSX.Element {
showAllStrategies,
toggleAllStrategies,
} = useSecondFactorSelection(signIn.supportedSecondFactors);
+ const webAuthnSupported = useIsWebAuthnSupported();
const onShowAlternativeMethodsClicked =
- signIn.supportedSecondFactors && signIn.supportedSecondFactors.length > 1 ? toggleAllStrategies : undefined;
+ (signIn.supportedSecondFactors?.filter(f => isOfferableSecondFactor(f, webAuthnSupported)).length ?? 0) > 1
+ ? toggleAllStrategies
+ : undefined;
React.useEffect(() => {
if (clerk.__internal_setActiveInProgress) {
@@ -85,6 +91,8 @@ function SignInFactorTwoInternal(): JSX.Element {
);
case 'backup_code':
return ;
+ case 'passkey':
+ return ;
case 'email_code':
return (
@@ -56,17 +59,20 @@ const AlternativeMethodsList = (props: AlternativeMethodsProps & { onHavingTroub
>
{supportedSecondFactors &&
- supportedSecondFactors.sort(backupCodePrefFactorComparator).map((factor, i) => (
- onFactorSelected(factor)}
- />
- ))}
+ supportedSecondFactors
+ .filter(factor => isOfferableSecondFactor(factor, webAuthnSupported))
+ .sort(backupCodePrefFactorComparator)
+ .map((factor, i) => (
+ onFactorSelected(factor)}
+ />
+ ))}
{onBackLinkClick && (
@@ -111,6 +117,8 @@ export function getButtonLabel(factor: SignInSecondFactor): LocalizationKey {
return localizationKeys('signIn.alternativeMethods.blockButton__emailLink', {
identifier: formatSafeIdentifier(factor.safeIdentifier) || '',
});
+ case 'passkey':
+ return localizationKeys('signIn.alternativeMethods.blockButton__passkey');
default:
((_: never) => _)(factor);
throw new Error('Invalid sign in strategy');
diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoPasskeyCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoPasskeyCard.tsx
new file mode 100644
index 00000000000..11d2d223422
--- /dev/null
+++ b/packages/ui/src/components/SignIn/SignInFactorTwoPasskeyCard.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+
+import { Card } from '@/ui/elements/Card';
+import { useCardState } from '@/ui/elements/contexts';
+import { Form } from '@/ui/elements/Form';
+import { Header } from '@/ui/elements/Header';
+
+import { descriptors, Flex, localizationKeys } from '../../customizables';
+import { useHandleAuthenticateWithPasskey } from './shared';
+
+type SignInFactorTwoPasskeyCardProps = {
+ onShowAlternativeMethodsClicked?: React.MouseEventHandler;
+};
+
+export const SignInFactorTwoPasskeyCard = (props: SignInFactorTwoPasskeyCardProps) => {
+ const { onShowAlternativeMethodsClicked } = props;
+ const card = useCardState();
+ // A successful attempt completes the sign-in, which the hook handles via
+ // setActive; the sign-in can't come back as needs_second_factor.
+ const authenticateWithPasskey = useHandleAuthenticateWithPasskey(() => Promise.resolve());
+
+ const handleSubmit: React.FormEventHandler = e => {
+ e.preventDefault();
+ return authenticateWithPasskey();
+ };
+
+ return (
+
+
+
+
+
+
+ {card.error}
+
+
+
+
+
+ {onShowAlternativeMethodsClicked && (
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx
index 287e934288c..f5b04763a77 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx
@@ -3,7 +3,7 @@ import type { SignInResource } from '@clerk/shared/types';
import { describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
-import { render, screen, waitFor } from '@/test/utils';
+import { mockWebAuthn, render, screen, waitFor } from '@/test/utils';
import { SignInFactorTwo } from '../SignInFactorTwo';
@@ -404,6 +404,78 @@ describe('SignInFactorTwo', () => {
});
});
});
+
+ describe('Passkey', () => {
+ it('falls back to the enrolled code factors when webauthn is not supported', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.startSignInFactorTwo({
+ supportPhoneCode: false,
+ supportTotp: true,
+ supportPasskey: true,
+ });
+ });
+ fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+ render(, { wrapper });
+
+ expect(screen.getAllByTestId('otp-input-segment').length).toBe(6);
+ expect(screen.queryByText('Use your passkey')).not.toBeInTheDocument();
+ });
+
+ mockWebAuthn(() => {
+ it('is the starting factor ahead of the enrolled code factors', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.startSignInFactorTwo({
+ supportPhoneCode: true,
+ supportTotp: true,
+ supportPasskey: true,
+ });
+ });
+ fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+ render(, { wrapper });
+
+ await screen.findByText('Use your passkey');
+ expect(screen.queryAllByTestId('otp-input-segment').length).toBe(0);
+ });
+
+ it('renders the passkey card when passkey is the only second factor', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.startSignInFactorTwo({
+ supportPhoneCode: false,
+ supportPasskey: true,
+ });
+ });
+ render(, { wrapper });
+
+ await screen.findByText('Use your passkey');
+ await screen.findByText(
+ "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.",
+ );
+ });
+
+ it('calls authenticateWithPasskey when clicking continue', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.startSignInFactorTwo({
+ supportPhoneCode: false,
+ supportPasskey: true,
+ });
+ });
+ fixtures.signIn.authenticateWithPasskey.mockResolvedValue({ status: 'complete' } as SignInResource);
+ const { userEvent } = render(, { wrapper });
+
+ await userEvent.click(screen.getByText('Continue'));
+
+ expect(fixtures.signIn.authenticateWithPasskey).toHaveBeenCalled();
+ });
+ });
+ });
});
describe('Use another method', () => {
@@ -485,6 +557,44 @@ describe('SignInFactorTwo', () => {
expect(await screen.findByText(/Authenticator/i)).toBeInTheDocument();
});
+ it('skips the passkey method when webauthn is not supported', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.startSignInFactorTwo({
+ supportPhoneCode: true,
+ supportTotp: true,
+ supportPasskey: true,
+ });
+ });
+
+ fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+ const { userEvent } = render(, { wrapper });
+ await userEvent.click(screen.getByText('Use another method'));
+ expect(await screen.findByText(/Send SMS code to \+/i)).toBeInTheDocument();
+ expect(screen.queryByText('Sign in with your passkey')).not.toBeInTheDocument();
+ });
+
+ mockWebAuthn(() => {
+ it('lists the passkey method and shows the passkey card when clicking it', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.startSignInFactorTwo({
+ supportPhoneCode: true,
+ supportTotp: true,
+ supportPasskey: true,
+ });
+ });
+
+ fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+ const { userEvent } = render(, { wrapper });
+ await userEvent.click(screen.getByText('Use another method'));
+ await userEvent.click(await screen.findByText('Sign in with your passkey'));
+ expect(await screen.findByText('Use your passkey')).toBeInTheDocument();
+ });
+ });
+
it('shows the SMS code input when clicking the Phone code method', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
diff --git a/packages/ui/src/components/SignIn/__tests__/utils.test.ts b/packages/ui/src/components/SignIn/__tests__/utils.test.ts
index bde4ce8afcb..eb227c249be 100644
--- a/packages/ui/src/components/SignIn/__tests__/utils.test.ts
+++ b/packages/ui/src/components/SignIn/__tests__/utils.test.ts
@@ -1,4 +1,4 @@
-import type { SignInResource } from '@clerk/shared/types';
+import type { SignInFactor, SignInResource } from '@clerk/shared/types';
import { describe, expect, it } from 'vitest';
import type { FormControlState } from '@/ui/utils/useFormControl';
@@ -6,8 +6,10 @@ import type { FormControlState } from '@/ui/utils/useFormControl';
import {
determineSalutation,
determineStartingSignInFactor,
+ determineStartingSignInSecondFactor,
getPreferredAlternativePhoneChannel,
getPreferredAlternativePhoneChannelForCombinedFlow,
+ isOfferableSecondFactor,
} from '../utils';
describe('determineStrategy(signIn, displayConfig)', () => {
@@ -20,9 +22,11 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({
- strategy: 'password',
- });
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual(
+ {
+ strategy: 'password',
+ },
+ );
});
it('selects based on user input in the previous step if password is not available', () => {
@@ -39,10 +43,12 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({
- strategy: 'phone_code',
- safeIdentifier: 'jdoe@example.com',
- });
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual(
+ {
+ strategy: 'phone_code',
+ safeIdentifier: 'jdoe@example.com',
+ },
+ );
});
it('selects by prioritizing email_code if all the above fail', () => {
@@ -59,10 +65,12 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({
- strategy: 'email_code',
- safeIdentifier: 'ccoe@example.com',
- });
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual(
+ {
+ strategy: 'email_code',
+ safeIdentifier: 'ccoe@example.com',
+ },
+ );
});
it('selects phone_code if all the above fail', () => {
@@ -75,10 +83,12 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({
- strategy: 'phone_code',
- safeIdentifier: 'jdoe@example.com',
- });
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual(
+ {
+ strategy: 'phone_code',
+ safeIdentifier: 'jdoe@example.com',
+ },
+ );
});
it('returns null if every other scenario', () => {
@@ -86,7 +96,9 @@ describe('determineStrategy(signIn, displayConfig)', () => {
identifier: undefined,
supportedFirstFactors: [],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toBeNull();
+ expect(
+ determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false),
+ ).toBeNull();
});
});
@@ -108,7 +120,7 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({
strategy: 'phone_code',
safeIdentifier: 'jdoe@example.com',
});
@@ -131,7 +143,7 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({
strategy: 'email_code',
safeIdentifier: 'ccoe@example.com',
});
@@ -150,7 +162,7 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({
strategy: 'phone_code',
safeIdentifier: 'jdoe@example.com',
});
@@ -164,7 +176,7 @@ describe('determineStrategy(signIn, displayConfig)', () => {
},
],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({
strategy: 'password',
});
});
@@ -174,7 +186,7 @@ describe('determineStrategy(signIn, displayConfig)', () => {
identifier: undefined,
supportedFirstFactors: [],
} as unknown as SignInResource;
- expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toBeNull();
+ expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toBeNull();
});
});
@@ -311,3 +323,29 @@ describe('getPreferredAlternativePhoneChannelForCombinedFlow', () => {
expect(result).toBeNull();
});
});
+
+describe('determineStartingSignInSecondFactor', () => {
+ const secondFactors = [{ strategy: 'totp' }, { strategy: 'passkey' }] as SignInFactor[];
+
+ it('prefers passkey when WebAuthn is supported', () => {
+ expect(determineStartingSignInSecondFactor(secondFactors, true)).toEqual({ strategy: 'passkey' });
+ });
+
+ it('falls back to the next factor when WebAuthn is not supported', () => {
+ expect(determineStartingSignInSecondFactor(secondFactors, false)).toEqual({ strategy: 'totp' });
+ });
+
+ it('falls back to the passkey when it is the only factor, even without WebAuthn support', () => {
+ expect(determineStartingSignInSecondFactor([{ strategy: 'passkey' }] as SignInFactor[], false)).toEqual({
+ strategy: 'passkey',
+ });
+ });
+});
+
+describe('isOfferableSecondFactor', () => {
+ it('offers passkey only when WebAuthn is supported', () => {
+ expect(isOfferableSecondFactor({ strategy: 'passkey' }, true)).toBe(true);
+ expect(isOfferableSecondFactor({ strategy: 'passkey' }, false)).toBe(false);
+ expect(isOfferableSecondFactor({ strategy: 'totp' }, false)).toBe(true);
+ });
+});
diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts
index 634128e86ee..16ae1656b44 100644
--- a/packages/ui/src/components/SignIn/shared.ts
+++ b/packages/ui/src/components/SignIn/shared.ts
@@ -12,6 +12,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router';
import { navigateOnSignInProtectGate } from './handleProtectCheck';
+import { isResetPasswordStrategy } from './utils';
/** Search param set when navigating from the start page "Forgot password?" action. */
export const SIGN_IN_RESET_PASSWORD_INTENT_PARAM = '__clerk_reset_password';
@@ -51,6 +52,20 @@ function useHandleAuthenticateWithPasskey(
}
switch (res.status) {
case 'complete':
+ // A passkey can complete the SECOND factor of a reset-password
+ // sign-in (reset password -> needs_second_factor -> passkey); that
+ // flow ends on the reset-password success screen, mirroring the
+ // other factor-two cards. Unreachable from factor-one usages of
+ // this hook, where the first-factor strategy is the passkey itself.
+ if (
+ isResetPasswordStrategy(res.firstFactorVerification?.strategy) &&
+ res.firstFactorVerification?.status === 'verified' &&
+ res.createdSessionId
+ ) {
+ const queryParams = new URLSearchParams();
+ queryParams.set('createdSessionId', res.createdSessionId);
+ return navigate(`../reset-password-success?${queryParams.toString()}`);
+ }
return setActive({
session: res.createdSessionId,
navigate: async ({ session, decorateUrl }) => {
diff --git a/packages/ui/src/components/SignIn/useSecondFactorSelection.ts b/packages/ui/src/components/SignIn/useSecondFactorSelection.ts
index 5e993d1759a..c658b0f34df 100644
--- a/packages/ui/src/components/SignIn/useSecondFactorSelection.ts
+++ b/packages/ui/src/components/SignIn/useSecondFactorSelection.ts
@@ -1,6 +1,7 @@
import type { SignInFactor } from '@clerk/shared/types';
import React from 'react';
+import { useIsWebAuthnSupported } from '../../hooks';
import { determineStartingSignInSecondFactor } from './utils';
const secondFactorKey = (factor: SignInFactor | null | undefined) => {
@@ -15,9 +16,10 @@ const secondFactorKey = (factor: SignInFactor | null | undefined) => {
};
export function useSecondFactorSelection(availableFactors: T[] | null) {
+ const webAuthnSupported = useIsWebAuthnSupported();
const lastPreparedFactorKeyRef = React.useRef('');
const [currentFactor, setCurrentFactor] = React.useState(
- () => determineStartingSignInSecondFactor(availableFactors) as T | null,
+ () => determineStartingSignInSecondFactor(availableFactors, webAuthnSupported) as T | null,
);
const [showAllStrategies, setShowAllStrategies] = React.useState(!currentFactor);
diff --git a/packages/ui/src/components/SignIn/utils.ts b/packages/ui/src/components/SignIn/utils.ts
index 697bd08adea..c57bee8327a 100644
--- a/packages/ui/src/components/SignIn/utils.ts
+++ b/packages/ui/src/components/SignIn/utils.ts
@@ -6,7 +6,6 @@ import type {
SignInStrategy,
} from '@clerk/shared/types';
import { titleize } from '@clerk/shared/underscore';
-import { isWebAuthnSupported } from '@clerk/shared/webauthn';
import { isEmail } from '@/ui/utils/emailUtils';
import { getPreferredPhoneCodeChannelByCountry } from '@/ui/utils/phoneUtils';
@@ -19,8 +18,12 @@ const factorForIdentifier = (i: string | null) => (f: SignInFactor) => {
return 'safeIdentifier' in f && f.safeIdentifier === i;
};
-function findPasskeyStrategy(factors: SignInFactor[]): SignInFactor | null {
- if (isWebAuthnSupported()) {
+export function isOfferableSecondFactor(factor: { strategy: SignInStrategy }, webAuthnSupported: boolean): boolean {
+ return factor.strategy !== 'passkey' || webAuthnSupported;
+}
+
+function findPasskeyStrategy(factors: SignInFactor[], webAuthnSupported: boolean): SignInFactor | null {
+ if (webAuthnSupported) {
// @ts-ignore
const passkeyFactor = factors.find(({ strategy }) => strategy === 'passkey');
@@ -34,8 +37,9 @@ function findPasskeyStrategy(factors: SignInFactor[]): SignInFactor | null {
function determineStrategyWhenPasswordIsPreferred(
factors: SignInFactor[],
identifier: string | null,
+ webAuthnSupported: boolean,
): SignInFactor | null {
- const passkeyFactor = findPasskeyStrategy(factors);
+ const passkeyFactor = findPasskeyStrategy(factors, webAuthnSupported);
if (passkeyFactor) {
return passkeyFactor;
}
@@ -46,8 +50,12 @@ function determineStrategyWhenPasswordIsPreferred(
return factors.find(factorForIdentifier(identifier)) || selected || null;
}
-function determineStrategyWhenOTPIsPreferred(factors: SignInFactor[], identifier: string | null): SignInFactor | null {
- const passkeyFactor = findPasskeyStrategy(factors);
+function determineStrategyWhenOTPIsPreferred(
+ factors: SignInFactor[],
+ identifier: string | null,
+ webAuthnSupported: boolean,
+): SignInFactor | null {
+ const passkeyFactor = findPasskeyStrategy(factors, webAuthnSupported);
if (passkeyFactor) {
return passkeyFactor;
}
@@ -69,14 +77,15 @@ export function determineStartingSignInFactor(
firstFactors: SignInFactor[] | null,
identifier: string | null,
preferredSignInStrategy: PreferredSignInStrategy,
+ webAuthnSupported: boolean,
): SignInFactor | null | undefined {
if (!firstFactors || firstFactors.length === 0) {
return null;
}
return preferredSignInStrategy === PREFERRED_SIGN_IN_STRATEGIES.Password
- ? determineStrategyWhenPasswordIsPreferred(firstFactors, identifier)
- : determineStrategyWhenOTPIsPreferred(firstFactors, identifier);
+ ? determineStrategyWhenPasswordIsPreferred(firstFactors, identifier, webAuthnSupported)
+ : determineStrategyWhenOTPIsPreferred(firstFactors, identifier, webAuthnSupported);
}
export function determineSalutation(signIn: Partial): string {
@@ -96,12 +105,22 @@ export function factorHasLocalStrategy(factor: SignInFactor | undefined | null):
return localStrategies.includes(factor.strategy);
}
-// The priority of second factors is: TOTP -> Phone code -> any other factor
-export function determineStartingSignInSecondFactor(secondFactors: SignInFactor[] | null): SignInFactor | null {
+// The priority of second factors is: Passkey -> TOTP -> Phone code -> any other factor.
+// Passkey is only offered where WebAuthn is supported — without it the passkey
+// card is a dead end, so fall through to the enrolled code-based factors instead.
+export function determineStartingSignInSecondFactor(
+ secondFactors: SignInFactor[] | null,
+ webAuthnSupported: boolean,
+): SignInFactor | null {
if (!secondFactors || secondFactors.length === 0) {
return null;
}
+ const passkeyFactor = findPasskeyStrategy(secondFactors, webAuthnSupported);
+ if (passkeyFactor) {
+ return passkeyFactor;
+ }
+
const totpFactor = secondFactors.find(f => f.strategy === 'totp');
if (totpFactor) {
return totpFactor;
@@ -112,7 +131,7 @@ export function determineStartingSignInSecondFactor(secondFactors: SignInFactor[
return phoneCodeFactor;
}
- return secondFactors[0];
+ return secondFactors.find(f => f.strategy !== 'passkey') ?? secondFactors[0];
}
const resetPasswordStrategies: SignInStrategy[] = ['reset_password_phone_code', 'reset_password_email_code'];
diff --git a/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx b/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx
index c3041271e07..99869ca51e6 100644
--- a/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx
+++ b/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx
@@ -102,6 +102,8 @@ export function getButtonLabel(factor: SessionVerificationSecondFactor): Localiz
return localizationKeys('reverification.alternativeMethods.blockButton__totp');
case 'backup_code':
return localizationKeys('reverification.alternativeMethods.blockButton__backupCode');
+ case 'passkey':
+ return localizationKeys('reverification.alternativeMethods.blockButton__passkey');
default:
throw new Error(`Invalid verification strategy: "${(factor as any).strategy}"`);
}
diff --git a/packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx b/packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx
new file mode 100644
index 00000000000..10c61c154f7
--- /dev/null
+++ b/packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx
@@ -0,0 +1,97 @@
+import { isClerkRuntimeError } from '@clerk/shared/error';
+import { __internal_WebAuthnAbortService } from '@clerk/shared/internal/clerk-js/passkeys';
+import { useSession } from '@clerk/shared/react';
+import React from 'react';
+
+import { Card } from '@/ui/elements/Card';
+import { useCardState } from '@/ui/elements/contexts';
+import { Form } from '@/ui/elements/Form';
+import { Header } from '@/ui/elements/Header';
+import { handleError } from '@/ui/utils/errorHandler';
+
+import { Button, Col, descriptors, localizationKeys } from '../../customizables';
+import { useAfterVerification } from './use-after-verification';
+
+type UVFactorTwoPasskeyCardProps = {
+ onShowAlternativeMethodsClicked?: React.MouseEventHandler;
+ showAlternativeMethods?: boolean;
+};
+
+export const UVFactorTwoPasskeyCard = (props: UVFactorTwoPasskeyCardProps) => {
+ const { onShowAlternativeMethodsClicked, showAlternativeMethods } = props;
+ const { session } = useSession();
+ const { handleVerificationResponse } = useAfterVerification();
+
+ const card = useCardState();
+
+ React.useEffect(() => {
+ return () => {
+ __internal_WebAuthnAbortService.abort();
+ };
+ }, []);
+
+ const handlePasskeysAttempt = () => {
+ // A second click while the WebAuthn ceremony is in flight would abort it
+ // and surface a passkey_operation_aborted error.
+ if (card.isLoading) {
+ return;
+ }
+
+ card.setLoading();
+ session
+ ?.verifyWithPasskey({ level: 'second_factor' })
+ .then(response => {
+ return handleVerificationResponse(response);
+ })
+ .catch(err => {
+ if (isClerkRuntimeError(err) && err.code === 'passkey_operation_aborted') {
+ return;
+ }
+ handleError(err, [], card.setError);
+ })
+ .finally(() => card.setIdle());
+
+ return;
+ };
+
+ return (
+
+
+
+
+
+
+ {card.error}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx b/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx
index 56f498bb48f..73d73e5fbf5 100644
--- a/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx
+++ b/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx
@@ -6,6 +6,7 @@ import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';
import { useEnvironment } from '../../contexts';
+import { useIsWebAuthnSupported } from '../../hooks';
import { localizationKeys } from '../../localization';
import { useRouter } from '../../router';
import { determineStartingSignInFactor, factorHasLocalStrategy } from '../SignIn/utils';
@@ -56,12 +57,13 @@ export function UserVerificationFactorOneInternal(): JSX.Element | null {
);
}, [sessionVerification.supportedFirstFactors]);
const { preferredSignInStrategy } = useEnvironment().displayConfig;
+ const webAuthnSupported = useIsWebAuthnSupported();
const [{ currentFactor }, setFactor] = React.useState<{
currentFactor: SignInFactor | undefined | null;
prevCurrentFactor: SignInFactor | undefined | null;
}>(() => ({
- currentFactor: determineStartingSignInFactor(availableFactors, null, preferredSignInStrategy),
+ currentFactor: determineStartingSignInFactor(availableFactors, null, preferredSignInStrategy, webAuthnSupported),
prevCurrentFactor: undefined,
}));
diff --git a/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx b/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx
index 518e2e7ae70..799ccf23d25 100644
--- a/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx
+++ b/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react';
import { withCardStateProvider } from '@/elements/contexts';
import { LoadingCard } from '@/elements/LoadingCard';
+import { useIsWebAuthnSupported } from '@/hooks';
import { useRouter } from '@/router';
import { useSecondFactorSelection } from '../SignIn/useSecondFactorSelection';
@@ -12,26 +13,31 @@ import { useUserVerificationSession, withUserVerificationSessionGuard } from './
import { sortByPrimaryFactor } from './utils';
import { UVFactorTwoAlternativeMethods } from './UVFactorTwoAlternativeMethods';
import { UVFactorTwoBackupCodeCard } from './UVFactorTwoBackupCodeCard';
+import { UVFactorTwoPasskeyCard } from './UVFactorTwoPasskeyCard';
import { UVFactorTwoPhoneCodeCard } from './UVFactorTwoPhoneCodeCard';
const SUPPORTED_STRATEGIES: SessionVerificationSecondFactor['strategy'][] = [
'phone_code',
'totp',
'backup_code',
+ 'passkey',
] as const;
export function UserVerificationFactorTwoComponent(): JSX.Element {
const { navigate } = useRouter();
const { data } = useUserVerificationSession();
const sessionVerification = data as SessionVerificationResource;
+ const webAuthnSupported = useIsWebAuthnSupported();
const availableFactors = useMemo(() => {
return (
sessionVerification.supportedSecondFactors
?.filter(factor => SUPPORTED_STRATEGIES.includes(factor.strategy))
+ // Only include passkey if the device supports it.
+ ?.filter(factor => factor.strategy !== 'passkey' || webAuthnSupported)
?.sort(sortByPrimaryFactor) || null
);
- }, [sessionVerification.supportedSecondFactors]);
+ }, [sessionVerification.supportedSecondFactors, webAuthnSupported]);
const {
currentFactor,
@@ -100,6 +106,13 @@ export function UserVerificationFactorTwoComponent(): JSX.Element {
onShowAlternativeMethodsClicked={hasAlternativeStrategies ? toggleAllStrategies : undefined}
/>
);
+ case 'passkey':
+ return (
+
+ );
default:
return ;
}
diff --git a/packages/ui/src/components/UserVerification/__tests__/UVFactorTwo.test.tsx b/packages/ui/src/components/UserVerification/__tests__/UVFactorTwo.test.tsx
index f2692566bc6..3234ad24d7c 100644
--- a/packages/ui/src/components/UserVerification/__tests__/UVFactorTwo.test.tsx
+++ b/packages/ui/src/components/UserVerification/__tests__/UVFactorTwo.test.tsx
@@ -1,8 +1,9 @@
+import { ClerkWebAuthnError } from '@clerk/shared/error';
import { waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
-import { render } from '@/test/utils';
+import { mockWebAuthn, render } from '@/test/utils';
import { clearFetchCache } from '../../../hooks';
import { UserVerificationFactorTwo } from '../UserVerificationFactorTwo';
@@ -78,6 +79,146 @@ describe('UserVerificationFactorTwo', () => {
await findByLabelText(/Backup code/i);
});
+ it('falls back to the enrolled code factors when webauthn is not supported', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ username: 'clerkuser' });
+ });
+ vi.spyOn(fixtures.session, 'startVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'totp' }, { strategy: 'passkey' }],
+ } as any);
+
+ const { findByText, queryByText } = render(, { wrapper });
+
+ await findByText('Enter the code generated by your authenticator app to continue');
+ expect(queryByText('Use your passkey')).not.toBeInTheDocument();
+ });
+
+ it('drops passkey when webauthn is not supported', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ username: 'clerkuser' });
+ });
+ vi.spyOn(fixtures.session, 'startVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'backup_code' }, { strategy: 'passkey' }],
+ } as any);
+
+ const { findByText, queryByText } = render(, { wrapper });
+
+ // The passkey factor is filtered out entirely, so the backup code card is
+ // the starting (and only) factor.
+ await findByText('Enter a backup code');
+ expect(queryByText('Use your passkey')).not.toBeInTheDocument();
+ });
+
+ mockWebAuthn(() => {
+ it('renders the passkey card when passkey is the only second factor', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ username: 'clerkuser' });
+ });
+ vi.spyOn(fixtures.session, 'startVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'passkey' }],
+ } as any);
+
+ const { findByText } = render(, { wrapper });
+
+ await findByText('Verification required');
+ await findByText(
+ 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.',
+ );
+ });
+
+ it('verifies with a passkey at the second factor level when clicking the button', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ username: 'clerkuser' });
+ });
+ vi.spyOn(fixtures.session, 'startVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'passkey' }],
+ } as any);
+ const verifyWithPasskey = vi.spyOn(fixtures.session, 'verifyWithPasskey').mockResolvedValue({
+ status: 'complete',
+ session: { id: '123' },
+ } as any);
+
+ const { userEvent, findByText } = render(, { wrapper });
+
+ await userEvent.click(await findByText('Use your passkey'));
+
+ expect(verifyWithPasskey).toHaveBeenCalledWith({ level: 'second_factor' });
+ });
+
+ it('starts with passkey ahead of the enrolled code factors', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ username: 'clerkuser' });
+ });
+ vi.spyOn(fixtures.session, 'startVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'totp' }, { strategy: 'passkey' }],
+ } as any);
+
+ const { findByText, queryByText } = render(, { wrapper });
+
+ await findByText(
+ 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.',
+ );
+ expect(queryByText('Enter the code generated by your authenticator app to continue')).not.toBeInTheDocument();
+ });
+
+ it('lists the remaining second factors as alternative methods', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ username: 'clerkuser' });
+ });
+ vi.spyOn(fixtures.session, 'startVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'totp' }, { strategy: 'passkey' }],
+ } as any);
+ vi.spyOn(fixtures.session, 'prepareSecondFactorVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'totp' }, { strategy: 'passkey' }],
+ } as any);
+
+ const { userEvent, findByText, getByText } = render(, { wrapper });
+
+ await findByText('Use another method');
+ await userEvent.click(getByText('Use another method'));
+ await userEvent.click(await findByText('Use your authenticator app'));
+
+ await findByText('Enter the code generated by your authenticator app to continue');
+ });
+
+ it('does not show an error when switching methods aborts a pending passkey request', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ username: 'clerkuser' });
+ });
+ vi.spyOn(fixtures.session, 'startVerification').mockResolvedValue({
+ status: 'needs_second_factor',
+ supportedSecondFactors: [{ strategy: 'totp' }, { strategy: 'passkey' }],
+ } as any);
+ let rejectPasskey: (err: unknown) => void = () => {};
+ vi.spyOn(fixtures.session, 'verifyWithPasskey').mockReturnValue(
+ new Promise((_, reject) => {
+ rejectPasskey = reject;
+ }),
+ );
+
+ const { userEvent, findByText, getByText, getByRole, queryByText } = render(, {
+ wrapper,
+ });
+
+ await userEvent.click(await findByText('Use your passkey'));
+ await userEvent.click(getByText('Use another method'));
+ const authenticatorAppButton = getByRole('button', { name: /Use your authenticator app/i });
+ expect(authenticatorAppButton).toBeDisabled();
+
+ rejectPasskey(new ClerkWebAuthnError('The operation was aborted.', { code: 'passkey_operation_aborted' }));
+
+ await waitFor(() => expect(authenticatorAppButton).toBeEnabled());
+ expect(queryByText(/The operation was aborted/)).not.toBeInTheDocument();
+ });
+ });
+
describe('Navigation', () => {
it('navigates to UserVerificationFactorOne component if user lands on SignInFactorTwo page but they should not', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
index dcbc9e9f64a..1a8844fde0a 100644
--- a/packages/ui/src/hooks/index.ts
+++ b/packages/ui/src/hooks/index.ts
@@ -7,6 +7,7 @@ export * from './useEnabledThirdPartyProviders';
export * from './useEnterpriseSSOLink';
export * from './useFetch';
export * from './useInView';
+export * from './useIsWebAuthnSupported';
export * from './useLoadingStatus';
export * from './useNavigateToFlowStart';
export * from './usePassword';
diff --git a/packages/ui/src/hooks/useIsWebAuthnSupported.ts b/packages/ui/src/hooks/useIsWebAuthnSupported.ts
new file mode 100644
index 00000000000..425f88126c7
--- /dev/null
+++ b/packages/ui/src/hooks/useIsWebAuthnSupported.ts
@@ -0,0 +1,13 @@
+import { useClerk } from '@clerk/shared/react';
+import { isWebAuthnSupported } from '@clerk/shared/webauthn';
+
+/**
+ * Whether this environment can complete a passkey ceremony. SDKs such as
+ * Electron override the browser check via `__internal_isWebAuthnSupported`,
+ * because `PublicKeyCredential` can exist even where passkeys won't work.
+ */
+export const useIsWebAuthnSupported = (): boolean => {
+ // @ts-expect-error - This is not a public API
+ const { __internal_isWebAuthnSupported } = useClerk();
+ return (__internal_isWebAuthnSupported ?? isWebAuthnSupported)();
+};
diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts
index 06b7c214458..d00ec09826c 100644
--- a/packages/ui/src/test/fixture-helpers.ts
+++ b/packages/ui/src/test/fixture-helpers.ts
@@ -128,6 +128,7 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => {
supportEmailCode?: boolean;
supportTotp?: boolean;
supportBackupCode?: boolean;
+ supportPasskey?: boolean;
supportResetPasswordEmail?: boolean;
supportResetPasswordPhone?: boolean;
};
@@ -226,6 +227,7 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => {
supportEmailCode,
supportTotp,
supportBackupCode,
+ supportPasskey,
supportResetPasswordEmail,
supportResetPasswordPhone,
} = params || {};
@@ -254,6 +256,7 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => {
...(supportEmailCode ? [{ strategy: 'email_code', safe_identifier: 'n*****@clerk.com' }] : []),
...(supportTotp ? [{ strategy: 'totp', safe_identifier: identifier || 'n*****@clerk.com' }] : []),
...(supportBackupCode ? [{ strategy: 'backup_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []),
+ ...(supportPasskey ? [{ strategy: 'passkey' }] : []),
],
user_data: { ...(createUserFixture() as any) },
} as SignInJSON;
diff --git a/packages/ui/src/utils/factorSorting.ts b/packages/ui/src/utils/factorSorting.ts
index eaab6768e6d..5a6296e4ce4 100644
--- a/packages/ui/src/utils/factorSorting.ts
+++ b/packages/ui/src/utils/factorSorting.ts
@@ -34,6 +34,7 @@ const STRATEGY_SORT_ORDER_ALL_STRATEGIES_BUTTONS = makeSortingOrderMap([
] as SignInStrategy[]);
const STRATEGY_SORT_ORDER_BACKUP_CODE_PREF = makeSortingOrderMap([
+ 'passkey',
'totp',
'phone_code',
'backup_code',