Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
707e4ad
feat(clerk-js,ui,shared,localizations): Support passkey as a second f…
dmoerner Jul 10, 2026
c23d0c4
fix: Address CodeRabbit review, add types to clerk/react
dmoerner Jul 13, 2026
1e0fbd7
fix: Degrade to first factor if Flowguard is unset
dmoerner Jul 13, 2026
8dcbbb2
chore: Bump supported fapi version
dmoerner Jul 14, 2026
858b804
Merge branch 'main' into daniel/user-3966-passkey-as-2fa-option
dmoerner Jul 27, 2026
09ee2e6
fix: Prefer passkey
dmoerner Aug 5, 2026
f2ceb9e
Merge branch 'main' into daniel/user-3966-passkey-as-2fa-option
dmoerner Aug 7, 2026
08f8f52
test(e2e): Add passkey second factor flows
dmoerner Aug 7, 2026
c6f7697
test(e2e): Use TOTP rather than a phone number for the second factor
dmoerner Aug 7, 2026
73c29e2
chore: bundlewatch
dmoerner Aug 10, 2026
3c7dea6
Merge branch 'main' into daniel/user-3966-passkey-as-2fa-option
dmoerner Aug 10, 2026
dbec351
Merge remote-tracking branch 'origin/main' into daniel/user-3966-pass…
dmoerner Aug 20, 2026
777ed6d
fix: Bump supported fapi version
dmoerner Aug 20, 2026
4f994ca
fix: Correct E2E staging setup
dmoerner Aug 20, 2026
7dd79eb
Merge branch 'main' into daniel/user-3966-passkey-as-2fa-option
dmoerner Aug 28, 2026
94d6dfd
chore(repo): merge main into daniel/user-3966-passkey-as-2fa-option
dmoerner Sep 21, 2026
43b1304
chore(repo): merge main into daniel/user-3966-passkey-as-2fa-option
dmoerner Sep 21, 2026
c192239
chore(clerk-js): bump bundlewatch limits
dmoerner Sep 21, 2026
0024a12
fix(ui): ignore aborted passkey request when switching reverification…
dmoerner Sep 25, 2026
022fc34
fix(clerk-js,shared): respect passkey flow param on MFA-pending sign-ins
dmoerner Sep 25, 2026
957d9c9
chore(repo): merge main into daniel/user-3966-passkey-as-2fa-option
dmoerner Sep 25, 2026
4a42277
Merge remote-tracking branch 'origin/main' into daniel/user-3966-pass…
dmoerner Oct 1, 2026
b104169
feat(ui, clerk-js): Support guarding UI capabilities
dmoerner Oct 1, 2026
1e10cca
fix: Hand-write changeset
dmoerner Oct 1, 2026
3241352
Revert "feat(ui, clerk-js): Support guarding UI capabilities"
dmoerner Oct 15, 2026
8900534
fix(ui): Respect the SDK's WebAuthn support check when offering passkeys
dmoerner Oct 15, 2026
f6e811d
docs(repo): Update passkey second factor changeset
dmoerner Oct 15, 2026
8bb2b54
Merge remote-tracking branch 'origin/main' into daniel/user-3966-pass…
dmoerner Oct 16, 2026
68c8876
ci(clerk-js): Raise clerk.native.js and clerk.no-rhc.js bundlewatch b…
dmoerner Oct 16, 2026
3dcf7ce
fix(clerk-js,ui): Coalesce passkey MFA prepare and avoid an empty fac…
dmoerner Oct 16, 2026
5d55d7a
test(e2e): Enable TOTP and passkey 2FA on the with-passkeys instance
dmoerner Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/passkey-second-factor.md
Original file line number Diff line number Diff line change
@@ -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 `<SignIn/>` and `<UserVerification/>`
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).
6 changes: 6 additions & 0 deletions integration/configs/with-passkeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
});
112 changes: 105 additions & 7 deletions integration/tests/passkeys.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => {
Expand All @@ -23,26 +26,33 @@ 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);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('registers a passkey through UserProfile', async ({ page, context }) => {
Expand Down Expand Up @@ -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();
});
});
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
49 changes: 44 additions & 5 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
SessionVerifyCreateParams,
SessionVerifyPrepareFirstFactorParams,
SessionVerifyPrepareSecondFactorParams,
SessionVerifyWithPasskeyParams,
TokenResource,
UserResource,
} from '@clerk/shared/types';
Expand All @@ -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';
Expand Down Expand Up @@ -324,10 +325,29 @@ export class Session extends BaseResource implements SessionResource {
return new SessionVerification(json);
};

verifyWithPasskey = async (): Promise<SessionVerificationResource> => {
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<SessionVerificationResource> => {
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
Expand Down Expand Up @@ -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,
Expand All @@ -380,11 +407,23 @@ export class Session extends BaseResource implements SessionResource {
attemptSecondFactorVerification = async (
attemptFactor: SessionVerifyAttemptSecondFactorParams,
): Promise<SessionVerificationResource> => {
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;

Expand Down
Loading
Loading