Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions .changeset/optional-setup-passkey-task.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Add the `setup-passkey` session task, offered after sign-up so a new user can enroll a passkey before continuing. It is never re-offered on sign-in.

Whether the task can be declined is per-instance, from the new `user_settings.passkey_settings.prompt_at_sign_up` setting: `off` creates no task, `optional` creates one the user may skip, and `required` creates one that must be resolved by enrolling a passkey. The task payload itself carries only its key, so clients read the mode from the environment. An absent or empty value means `off`, since settings cached before the field existed do not carry it.

New public API on `Session`:

```ts
await clerk.session.skipTask('setup-passkey');
```

`skipTask(taskKey)` posts to `POST /v1/client/sessions/{sessionId}/tasks/{taskKey}/skip` and returns the updated session, whose `currentTask` has advanced to the next pending task. It is typed against the `SessionTask['key']` union, and it is the only way to clear an optional task, so custom (headless) sign-up flows need it to let a user decline. A task that cannot be skipped — a `required` passkey prompt, or `reset-password`, `setup-mfa` and `choose-organization` — is rejected with a `session_task_not_skippable` API error, which is thrown to the caller.

Also included:

- `SessionTask['key']` now includes `'setup-passkey'`, routed at `/tasks/setup-passkey`.
- `PasskeySettingsData` gains `prompt_at_sign_up`, typed as the new `PasskeyPromptAtSignUp` union.
- The prebuilt task card explains what a passkey is for readers who have never met one. In `optional` mode it pairs "Create a passkey" with a visible "Not now", and a device with no platform authenticator never sees the card at all — the task is declined automatically before anything renders. In `required` mode there is no decline affordance, the subtitle states the requirement rather than framing the task as an offer, and a device that cannot create a passkey gets an explicit dead-end explanation instead of a silent skip that the Frontend API would reject anyway.
- Cancelling the OS WebAuthn dialog returns to the card rather than stranding the flow.
- Copy lives under the new `taskSetupPasskey` localization keys.
- `SUPPORTED_FAPI_VERSION` moves to `2026-08-20`, the Frontend API version that emits this task.
19 changes: 19 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,25 @@ export class Session extends BaseResource implements SessionResource {
return this;
};

/**
* Declines an optional session task so the session can move on to the next one.
*
* Only optional tasks can be skipped. A required task is rejected by the Frontend API with a
* `session_task_not_skippable` error, which is thrown to the caller rather than swallowed.
*
* The response piggybacks the client, so the client resource is refreshed as part of the
* request and `currentTask` ends up pointing at the next pending task (or nothing at all).
*/
skipTask = async (taskKey: SessionTask['key']): Promise<SessionResource> => {
// `path()` percent-encodes its argument, so the nested segments are joined by hand.
const json = await BaseResource._fetch<SessionJSON>({
method: 'POST',
path: `${this.path('tasks')}/${encodeURIComponent(taskKey)}/skip`,
});

return this.fromJSON((json?.response || json) as SessionJSON);
};

/**
* Internal method to touch the session without updating the client or explicitly emitting the TokenUpdate event.
*
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
passkeySettings: PasskeySettingsData = {
allow_autofill: false,
show_sign_in_button: false,
prompt_at_sign_up: 'off',
};
passwordSettings: PasswordSettingsData = {} as PasswordSettingsData;
signIn: SignInData = {
Expand Down
76 changes: 76 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,82 @@ describe('Session', () => {
});
});

describe('skipTask()', () => {
const mockSessionData = {
status: 'pending',
id: 'session_1',
object: 'session',
user: createUser({}),
last_active_organization_id: null,
actor: null,
tasks: [{ key: 'setup-passkey' }, { key: 'choose-organization' }],
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
} as unknown as SessionJSON;

beforeEach(() => {
BaseResource.clerk = clerkMock();
});

afterEach(() => {
BaseResource.clerk = null as any;
});

it('posts to the task skip endpoint without a body', async () => {
const session = new Session(mockSessionData);
const requestSpy = BaseResource.clerk.getFapiClient().request as Mock;
requestSpy.mockResolvedValue({ payload: { response: mockSessionData }, status: 200 });

await session.skipTask('setup-passkey');

expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
path: '/client/sessions/session_1/tasks/setup-passkey/skip',
}),
expect.anything(),
);
expect(requestSpy.mock.calls[0][0].body).toBeUndefined();
});

it('advances currentTask from the response', async () => {
const session = new Session(mockSessionData);
const requestSpy = BaseResource.clerk.getFapiClient().request as Mock;
requestSpy.mockResolvedValue({
payload: { response: { ...mockSessionData, tasks: [{ key: 'choose-organization' }] } },
status: 200,
});

expect(session.currentTask).toEqual({ key: 'setup-passkey' });

await session.skipTask('setup-passkey');

expect(session.currentTask).toEqual({ key: 'choose-organization' });
});

// A required task cannot be skipped, and the rejection must reach the caller so the UI can
// surface it instead of silently pretending the task is gone.
it('propagates the API error for a task that cannot be skipped', async () => {
const session = new Session(mockSessionData);
const requestSpy = BaseResource.clerk.getFapiClient().request as Mock;
requestSpy.mockResolvedValue({
payload: {
errors: [
{
code: 'session_task_not_skippable',
message: 'Task is not skippable',
long_message: 'This session task cannot be skipped.',
},
],
},
status: 400,
});

await expect(session.skipTask('setup-mfa')).rejects.toThrow(ClerkAPIResponseError);
expect(session.currentTask).toEqual({ key: 'setup-passkey' });
});
});

describe('isAuthorized()', () => {
it('user with permission to delete the organization should be able to delete the organization', async () => {
const session = new Session({
Expand Down
18 changes: 18 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1756,6 +1756,24 @@ export const enUS: LocalizationResource = {
},
},
},
taskSetupPasskey: {
formButtonPrimary: 'Create a passkey',
formButtonSkip: 'Not now',
infoText: 'Your passkey never leaves this device, so there is nothing to remember and nothing to leak.',
signOut: {
actionLink: 'Sign out',
actionText: 'Signed in as {{identifier}}',
},
subtitle: 'Next time, sign in with your fingerprint, face, or screen lock instead of a password.',
subtitle__required:
'Your account requires a passkey to finish signing up. You will sign in with your fingerprint, face, or screen lock instead of a password.',
title: 'Set up a passkey',
unsupportedDevice: {
subtitle:
'This account requires a passkey, but this browser or device cannot create one. Sign in again from a device that unlocks with a fingerprint, face, or screen lock.',
title: "This device can't create a passkey",
},
},
unstable__errors: {
action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.",
already_a_member_in_organization: '{{email}} is already a member of the organization.',
Expand Down
2 changes: 2 additions & 0 deletions packages/msw/SessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const EXCLUDED_KEYS = new Set([
'prepareSecondFactorVerification',
'remove',
'resolve',
'skipTask',
'startVerification',
'touch',
'verifyWithPasskey',
Expand Down Expand Up @@ -110,6 +111,7 @@ export class SessionService {
prepareFirstFactorVerification: async () => ({}) as any,
prepareSecondFactorVerification: async () => ({}) as any,
remove: async () => session,
skipTask: async () => session,
startVerification: async () => ({}) as any,
touch: async () => session,
verifyWithPasskey: async () => ({}) as any,
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/internal/clerk-js/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const SIGN_UP_MODES = {
} satisfies Record<string, SignUpModes>;

// 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';
1 change: 1 addition & 0 deletions packages/shared/src/internal/clerk-js/sessionTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], stri
'choose-organization': 'choose-organization',
'reset-password': 'reset-password',
'setup-mfa': 'setup-mfa',
'setup-passkey': 'setup-passkey',
} as const;

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2869,6 +2869,18 @@ export type TaskSetupMFAProps = {
appearance?: ClerkAppearanceTheme;
};

/** @generateWithEmptyComment */
export type TaskSetupPasskeyProps = {
/**
* Full URL or path to navigate to after successfully resolving all tasks
*/
redirectUrlComplete: string;
/**
* Customization options to fully match the Clerk components to your own brand. These options serve as overrides and will be merged with the global `appearance` configuration (if one is provided). See the [`Appearance`](https://clerk.com/docs/guides/customizing-clerk/appearance-prop/overview) docs for more information.
*/
appearance?: ClerkAppearanceTheme;
};

/** @generateWithEmptyComment */
export type CreateOrganizationInvitationParams = {
/**
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,22 @@ export type __internal_LocalizationResource = {
actionLink: LocalizationValue;
};
};
taskSetupPasskey: {
title: LocalizationValue;
subtitle: LocalizationValue;
subtitle__required: LocalizationValue;
infoText: LocalizationValue;
formButtonPrimary: LocalizationValue;
formButtonSkip: LocalizationValue;
unsupportedDevice: {
title: LocalizationValue;
subtitle: LocalizationValue;
};
signOut: {
actionText: LocalizationValue<'identifier'>;
actionLink: LocalizationValue;
};
};
web3SolanaWalletButtons: {
connect: LocalizationValue<'walletName'>;
continue: LocalizationValue<'walletName'>;
Expand Down
10 changes: 9 additions & 1 deletion packages/shared/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,14 @@ export interface SessionResource extends ClerkResource {
* Updates the session's last active timestamp to the current time. This method should be called periodically to indicate ongoing user activity and prevent the session from becoming stale. The updated timestamp is used for session management and analytics purposes.
*/
touch: (params?: SessionTouchParams) => Promise<SessionResource>;
/**
* Declines an optional [session task](https://clerk.com/docs/guides/configure/session-tasks) so the session can move on to the next one.
*
* Only optional tasks can be skipped. A required task (for example `reset-password`, `setup-mfa` or `choose-organization`) is rejected with a `session_task_not_skippable` API error, which is thrown rather than swallowed.
* @param taskKey - The key of the task to decline, as found on `Session.currentTask`.
* @returns The updated [`Session`](https://clerk.com/docs/reference/objects/session), whose `currentTask` is now the next pending task, if any.
*/
skipTask: (taskKey: SessionTask['key']) => Promise<SessionResource>;
/**
* Gets the current user's [session token](https://clerk.com/docs/guides/sessions/session-tokens) or a [custom JWT template](https://clerk.com/docs/guides/sessions/jwt-templates).
*
Expand Down Expand Up @@ -494,7 +502,7 @@ export interface SessionTask {
/**
* A unique identifier for the task
*/
key: 'choose-organization' | 'reset-password' | 'setup-mfa';
key: 'choose-organization' | 'reset-password' | 'setup-mfa' | 'setup-passkey';
}

/** @generateWithEmptyComment */
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/types/userSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,22 @@ export type AttackProtectionData = {
};
};

/**
* Whether a user is prompted to enroll a passkey after signing up, and whether that prompt can be declined.
*
* `off` creates no prompt at all, `optional` creates a `setup-passkey` session task the user may skip, and
* `required` creates one that must be resolved by enrolling a passkey.
*/
export type PasskeyPromptAtSignUp = 'off' | 'optional' | 'required';

export type PasskeySettingsData = {
allow_autofill: boolean;
show_sign_in_button: boolean;
/**
* Optional, and possibly empty, because settings cached before this field existed do not carry it.
* Both an absent and an empty value mean `off`.
*/
prompt_at_sign_up?: PasskeyPromptAtSignUp | '';
};

export type OAuthProviders = {
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/components/SessionTasks/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import {
TaskChooseOrganizationContext,
TaskResetPasswordContext,
TaskSetupMFAContext,
TaskSetupPasskeyContext,
useSessionTasksContext,
} from '../../contexts/components/SessionTasks';
import { Route, Switch, useRouter } from '../../router';
import { TaskChooseOrganization } from './tasks/TaskChooseOrganization';
import { TaskResetPassword } from './tasks/TaskResetPassword';
import { TaskSetupMFA } from './tasks/TaskSetupMfa';
import { TaskSetupPasskey } from './tasks/TaskSetupPasskey';

const SessionTasksStart = () => {
const clerk = useClerk();
Expand Down Expand Up @@ -108,6 +110,13 @@ function SessionTasksRoutes(): JSX.Element {
<TaskSetupMFA />
</TaskSetupMFAContext.Provider>
</Route>
<Route path={INTERNAL_SESSION_TASK_ROUTE_BY_KEY['setup-passkey']}>
<TaskSetupPasskeyContext.Provider
value={{ componentName: 'TaskSetupPasskey', redirectUrlComplete: ctx.redirectUrlComplete }}
>
<TaskSetupPasskey />
</TaskSetupPasskeyContext.Provider>
</Route>
<Route index>
<SessionTasksStart />
</Route>
Expand Down
Loading
Loading