Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/twelve-fans-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

[Experimental] Signal MFA support
56 changes: 56 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ import type {
SamlConfig,
SignInCreateParams,
SignInFirstFactor,
SignInFutureBackupCodeVerifyParams,
SignInFutureCreateParams,
SignInFutureEmailCodeSendParams,
SignInFutureEmailCodeVerifyParams,
SignInFutureFinalizeParams,
SignInFutureMFAPhoneCodeVerifyParams,
SignInFuturePasswordParams,
SignInFuturePhoneCodeSendParams,
SignInFuturePhoneCodeVerifyParams,
SignInFutureResetPasswordSubmitParams,
SignInFutureResource,
SignInFutureSSOParams,
SignInFutureTOTPVerifyParams,
SignInIdentifier,
SignInJSON,
SignInJSONSnapshot,
Expand Down Expand Up @@ -515,6 +518,13 @@ class SignInFuture implements SignInFutureResource {
verifyCode: this.verifyPhoneCode.bind(this),
};

mfa = {
sendPhoneCode: this.sendMFAPhoneCode.bind(this),
verifyPhoneCode: this.verifyMFAPhoneCode.bind(this),
verifyTOTP: this.verifyTOTP.bind(this),
verifyBackupCode: this.verifyBackupCode.bind(this),
};

constructor(readonly resource: SignIn) {}

get status() {
Expand Down Expand Up @@ -688,6 +698,52 @@ class SignInFuture implements SignInFutureResource {
});
}

async sendMFAPhoneCode(): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const phoneCodeFactor = this.resource.supportedSecondFactors?.find(f => f.strategy === 'phone_code');

if (!phoneCodeFactor) {
throw new Error('Phone code factor not found');
}

const { phoneNumberId } = phoneCodeFactor;
await this.resource.__internal_basePost({
body: { phoneNumberId, strategy: 'phone_code' },
action: 'prepare_second_factor',
});
});
}
Comment on lines +701 to +715
Copy link
Contributor

@coderabbitai coderabbitai bot Aug 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against misuse, support optional channel, and improve error message.

  • Add id guard like other flows.
  • Accept optional { channel } (default 'sms') for parity with first factor.
  • Make the error actionable.

Apply this diff:

-  async sendMFAPhoneCode(): Promise<{ error: unknown }> {
-    return runAsyncResourceTask(this.resource, async () => {
-      const phoneCodeFactor = this.resource.supportedSecondFactors?.find(f => f.strategy === 'phone_code');
+  async sendMFAPhoneCode(params?: { channel?: PhoneCodeChannel }): Promise<{ error: unknown }> {
+    return runAsyncResourceTask(this.resource, async () => {
+      if (!this.resource.id) {
+        throw new Error('Cannot send MFA phone code without an active sign-in. Call create/first factor first.');
+      }
+      const phoneCodeFactor = this.resource.supportedSecondFactors?.find(f => f.strategy === 'phone_code');
 
       if (!phoneCodeFactor) {
-        throw new Error('Phone code factor not found');
+        throw new Error(
+          'MFA phone code factor not available. Ensure the user has a verified phone number and the first factor succeeded.',
+        );
       }
 
       const { phoneNumberId } = phoneCodeFactor;
+      const channel = params?.channel ?? 'sms';
       await this.resource.__internal_basePost({
-        body: { phoneNumberId, strategy: 'phone_code' },
+        body: { phoneNumberId, strategy: 'phone_code', channel },
         action: 'prepare_second_factor',
       });
     });
   }

Additional change required at the top of this file to import the type:

// near the other type imports from '@clerk/types'
import type { PhoneCodeChannel } from '@clerk/types';
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/SignIn.ts around lines 701 to 715, the
sendMFAPhoneCode flow needs an id guard, support for an optional channel param
(default 'sms'), and a clearer error message; add an `id` guard similar to other
flows (validate phoneNumberId exists and throw a descriptive error like "Phone
code factor missing or missing id"), change the method signature to accept an
optional `{ channel?: PhoneCodeChannel }` and pass `channel` (defaulting to
'sms') into the body when calling __internal_basePost, and ensure the thrown
error is actionable; also add the required type import at the top of the file:
`import type { PhoneCodeChannel } from '@clerk/types';`.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whatsapp is available as a supported channel only as a first factor during sign up

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nikosdouvlis Thank you for the clarification! You're absolutely right - if WhatsApp is only available as a first factor during sign up, then adding channel support to the MFA phone code method would be inappropriate.

Let me revise my suggestion to focus on the essential improvements without the channel parameter:

-  async sendMFAPhoneCode(): Promise<{ error: unknown }> {
+  async sendMFAPhoneCode(): Promise<{ error: unknown }> {
     return runAsyncResourceTask(this.resource, async () => {
+      if (!this.resource.id) {
+        throw new Error('Cannot send MFA phone code without an active sign-in. Call create/first factor first.');
+      }
       const phoneCodeFactor = this.resource.supportedSecondFactors?.find(f => f.strategy === 'phone_code');
 
       if (!phoneCodeFactor) {
-        throw new Error('Phone code factor not found');
+        throw new Error(
+          'MFA phone code factor not available. Ensure the user has a verified phone number and the first factor succeeded.',
+        );
       }
 
       const { phoneNumberId } = phoneCodeFactor;
       await this.resource.__internal_basePost({
         body: { phoneNumberId, strategy: 'phone_code' },
         action: 'prepare_second_factor',
       });
     });
   }

This keeps the essential improvements (id guard and better error message) while respecting the platform's channel limitations for MFA.


✏️ Learnings added
Learnt from: nikosdouvlis
PR: clerk/javascript#6659
File: packages/clerk-js/src/core/resources/SignIn.ts:701-715
Timestamp: 2025-08-29T09:10:55.179Z
Learning: WhatsApp channel is only available as a supported channel for phone codes during sign up as a first factor, not for MFA (second factor) flows.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


async verifyMFAPhoneCode(params: SignInFutureMFAPhoneCodeVerifyParams): Promise<{ error: unknown }> {
const { code } = params;
return runAsyncResourceTask(this.resource, async () => {
await this.resource.__internal_basePost({
body: { code, strategy: 'phone_code' },
action: 'attempt_second_factor',
});
});
}

async verifyTOTP(params: SignInFutureTOTPVerifyParams): Promise<{ error: unknown }> {
const { code } = params;
return runAsyncResourceTask(this.resource, async () => {
await this.resource.__internal_basePost({
body: { code, strategy: 'totp' },
action: 'attempt_second_factor',
});
});
}

async verifyBackupCode(params: SignInFutureBackupCodeVerifyParams): Promise<{ error: unknown }> {
const { code } = params;
return runAsyncResourceTask(this.resource, async () => {
await this.resource.__internal_basePost({
body: { code, strategy: 'backup_code' },
action: 'attempt_second_factor',
});
});
}

async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: unknown }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.resource, async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/stateProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export class StateProxy implements State {
'submitPassword',
] as const),
phoneCode: this.wrapMethods(() => target().phoneCode, ['sendCode', 'verifyCode'] as const),
mfa: this.wrapMethods(() => target().mfa, [
'sendPhoneCode',
'verifyPhoneCode',
'verifyTOTP',
'verifyBackupCode',
] as const),
},
};
}
Expand Down
18 changes: 18 additions & 0 deletions packages/types/src/signInFuture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ export interface SignInFutureSSOParams {
redirectCallbackUrl: string;
}

export interface SignInFutureMFAPhoneCodeVerifyParams {
code: string;
}

export interface SignInFutureTOTPVerifyParams {
code: string;
}

export interface SignInFutureBackupCodeVerifyParams {
code: string;
}

export interface SignInFutureFinalizeParams {
navigate?: SetActiveNavigate;
}
Expand All @@ -76,5 +88,11 @@ export interface SignInFutureResource {
submitPassword: (params: SignInFutureResetPasswordSubmitParams) => Promise<{ error: unknown }>;
};
sso: (params: SignInFutureSSOParams) => Promise<{ error: unknown }>;
mfa: {
sendPhoneCode: () => Promise<{ error: unknown }>;
verifyPhoneCode: (params: SignInFutureMFAPhoneCodeVerifyParams) => Promise<{ error: unknown }>;
verifyTOTP: (params: SignInFutureTOTPVerifyParams) => Promise<{ error: unknown }>;
verifyBackupCode: (params: SignInFutureBackupCodeVerifyParams) => Promise<{ error: unknown }>;
};
finalize: (params?: SignInFutureFinalizeParams) => Promise<{ error: unknown }>;
}
Loading