Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
84d79f6
feat(ui): Support signUpIfMissing with Clerk <SignIn> component
dmoerner Feb 25, 2026
bc2274f
Merge branch 'main' into daniel/user-4769-sign-up-if-missing-clerk-co…
dmoerner Feb 27, 2026
721b518
Merge branch 'main' into daniel/user-4769-sign-up-if-missing-clerk-co…
dmoerner Mar 2, 2026
40d8ceb
fix: Also add transferable status to SignInFuture email link
dmoerner Mar 2, 2026
45b2b6d
Merge branch 'main' into daniel/user-4769-sign-up-if-missing-clerk-co…
dmoerner Mar 23, 2026
16207c3
fix: Explicitly guard restricted modes and username sign in
dmoerner Mar 23, 2026
c84148b
fix: Improve error handling in transfer flow
dmoerner Mar 23, 2026
7a4025b
fix: Align sign up if missing with oauth transfer navigation
dmoerner Apr 8, 2026
34c36bb
fix: Correct imports and function type
dmoerner Apr 8, 2026
5a674b8
fix: Revert unnecessary addition of transferable to email link errors
dmoerner Apr 8, 2026
01bc760
fix: Better document the literal type for email link status
dmoerner Apr 8, 2026
41c12ba
fix: import ordering
dmoerner Apr 9, 2026
f88900e
Merge branch 'main' into daniel/user-4769-sign-up-if-missing-clerk-co…
dmoerner Jul 9, 2026
a3d6e9b
chore: Update changeset
dmoerner Jul 9, 2026
625bdc2
fix(*): Address review feedback on sign-up-if-missing transfer
dmoerner Jul 9, 2026
77325fd
fix(ui): Distinct copy for transferable email-link verification
dmoerner Jul 9, 2026
99e9770
Merge branch 'main' into daniel/user-4769-sign-up-if-missing-clerk-co…
wobsoriano Jul 28, 2026
c317949
Merge branch 'main' into daniel/user-4769-sign-up-if-missing-clerk-co…
wobsoriano Jul 28, 2026
76c067e
chore: bundlewatch fix
wobsoriano Jul 28, 2026
2723ff0
fix(ui): Guard attackProtection read against clerk-js version skew
dmoerner Jul 29, 2026
a505f95
test(ui): Mock the real FAPI error code for sign-up-if-missing transfers
dmoerner Jul 29, 2026
ec83f7c
fix(ui): Let whichever tab owns the sign-in perform the sign-up transfer
dmoerner Jul 29, 2026
1cbe26e
feat(ui): Warn when password and enumeration protection strand sign-i…
dmoerner Jul 29, 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
14 changes: 14 additions & 0 deletions .changeset/fancy-candies-slide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
'@clerk/localizations': minor
---

Support sign-in-or-sign-up combined flow with Clerk <SignIn> component
when strict enumeration protection is enabled.

On development instances, `<SignIn>` now logs a warning when the sign-in-or-up flow is rendered on an
instance that has both password and strict enumeration protection enabled. In that configuration
visitors without an account are routed to the password screen and cannot complete a sign-up, so the
warning names both settings and how to resolve them.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "75KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "76KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
85 changes: 36 additions & 49 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ import type {
SignOut,
SignOutCallback,
SignOutOptions,
SignUpField,
SignUpProps,
SignUpRedirectOptions,
SignUpResource,
Expand Down Expand Up @@ -149,7 +148,6 @@ import { ModuleManager } from '@/utils/moduleManager';
import {
ALLOWED_PROTOCOLS,
buildURL,
completeSignUpFlow,
createAllowedRedirectOrigins,
createBeforeUnloadTracker,
createPageLifecycle,
Expand All @@ -162,6 +160,7 @@ import {
isError,
isOrganizationId,
isRedirectForFAPIInitiatedFlow,
navigateToNextStepSignUp,
removeClerkQueryParam,
requiresUserInput,
stripOrigin,
Expand Down Expand Up @@ -2288,6 +2287,14 @@ export class Clerk implements ClerkInterface {
throw new EmailLinkError(EmailLinkErrorCodeStatus.Expired);
} else if (verificationStatus === 'client_mismatch') {
throw new EmailLinkError(EmailLinkErrorCodeStatus.ClientMismatch);
} else if (verificationStatus === 'transferable') {

@wobsoriano wobsoriano Jul 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since no session exists here yet, if the verification link replaces the polling tab or the original tab is gone, nothing runs the transfer and the user is told to return to a tab that no longer exists, correct? Should this branch complete the transfer on the current client and mirror the verified handling further down?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a good point. I went back and forth here on how to handle the polling tab or the new tab. I'm going to switch to using the other tab. Currently re-running my E2E tests with this change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ec83f7c

// signUpIfMissing flow: the email was verified but the user doesn't exist, so there is
// no session to complete here. The sign-up transfer is banked on the client that owns
// the sign-in; consuming it is left to the caller, which knows where to route next.
if (typeof params.onVerifiedOnOtherDevice === 'function') {
params.onVerifiedOnOtherDevice();
}
return;
} else if (verificationStatus !== 'verified') {
throw new EmailLinkError(EmailLinkErrorCodeStatus.Failed);
}
Expand Down Expand Up @@ -2434,54 +2441,20 @@ export class Clerk implements ClerkInterface {

const redirectUrls = new RedirectUrls(this.#options, params);

const navigateToContinueSignUp = makeNavigate(
const continueSignUpUrl =
params.continueSignUpUrl ||
buildURL(
{
base: displayConfig.signUpUrl,
hashPath: '/continue',
},
{ stringify: true },
),
);

const navigateToSignUpProtectCheck = makeNavigate(
buildURL({ base: displayConfig.signUpUrl, hashPath: '/continue' }, { stringify: true });
const verifyEmailAddressUrl =
params.verifyEmailAddressUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-email-address' }, { stringify: true });
const verifyPhoneNumberUrl =
params.verifyPhoneNumberUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true });
const signUpProtectCheckUrl =
params.signUpProtectCheckUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
);
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true });

const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => {
// A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate
// check must run BEFORE the generic missing-fields short-circuit below — otherwise the
// OAuth/SAML callback would land on /continue instead of the challenge.
if (signUp.protectCheck || missingFields.includes('protect_check')) {
return navigateToSignUpProtectCheck();
}

if (missingFields.length) {
return navigateToContinueSignUp();
}

return completeSignUpFlow({
signUp,
verifyEmailPath:
params.verifyEmailAddressUrl ||
buildURL(
{
base: displayConfig.signUpUrl,
hashPath: '/verify-email-address',
},
{ stringify: true },
),
verifyPhonePath:
params.verifyPhoneNumberUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }),
protectCheckPath:
params.signUpProtectCheckUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
navigate,
});
};
const navigateToSignUpProtectCheck = makeNavigate(signUpProtectCheckUrl);

const signInUrl = params.signInUrl || displayConfig.signInUrl;
const signUpUrl = params.signUpUrl || displayConfig.signUpUrl;
Expand Down Expand Up @@ -2627,7 +2600,14 @@ export class Clerk implements ClerkInterface {
},
});
case 'missing_requirements':
return navigateToNextStepSignUp({ missingFields: res.missingFields });
return navigateToNextStepSignUp({
signUp: res,
continueSignUpUrl,
verifyEmailAddressUrl,
verifyPhoneNumberUrl,
signUpProtectCheckUrl,
navigate,
});
default:
clerkOAuthCallbackDidNotCompleteSignInSignUp('sign in');
}
Expand Down Expand Up @@ -2682,7 +2662,14 @@ export class Clerk implements ClerkInterface {
}

if (su.externalAccountStatus === 'verified' && su.status === 'missing_requirements') {
return navigateToNextStepSignUp({ missingFields: signUp.missingFields });
return navigateToNextStepSignUp({
signUp,
continueSignUpUrl,
verifyEmailAddressUrl,
verifyPhoneNumberUrl,
signUpProtectCheckUrl,
navigate,
});
}

if (this.session?.currentTask) {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ import {
import { eventBus } from '../events';
import { BaseResource, UserData, Verification } from './internal';

/**
* Terminal states for email-link verification polling: `verified` (success), `expired`
* (link timed out), or `transferable` (`signUpIfMissing` flows — the address was verified
* but no user exists, so the caller transfers to sign-up). Shared by the legacy
* `createEmailLinkFlow` poll and `SignInFuture.waitForEmailLinkVerification` so the two
* loops can't drift apart.
*/
const isTerminalEmailLinkVerificationStatus = (status: string | null) =>
status === 'verified' || status === 'expired' || status === 'transferable';

export class SignIn extends BaseResource implements SignInResource {
pathRoot = '/client/sign_ins';

Expand Down Expand Up @@ -335,8 +345,7 @@ export class SignIn extends BaseResource implements SignInResource {
void run(() => {
return this.reload()
.then(res => {
const status = res[verificationKey].status;
if (status === 'verified' || status === 'expired') {
if (isTerminalEmailLinkVerificationStatus(res[verificationKey].status)) {
stop();
resolve(res);
}
Expand Down Expand Up @@ -1152,8 +1161,7 @@ class SignInFuture implements SignInFutureResource {
void run(async () => {
try {
const res = await this.#resource.__internal_baseGet();
const status = res.firstFactorVerification.status;
if (status === 'verified' || status === 'expired') {
if (isTerminalEmailLinkVerificationStatus(res.firstFactorVerification.status)) {
stop();
resolve(res);
}
Expand Down
12 changes: 12 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AttackProtectionData,
Attributes,
EnterpriseSSOSettings,
OAuthProviders,
Expand Down Expand Up @@ -103,6 +104,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
name: 'passkey',
},
};
attackProtection: AttackProtectionData = { enumeration_protection: { enabled: false } };
enterpriseSSO: EnterpriseSSOSettings = {
enabled: false,
self_serve_sso: false,
Expand Down Expand Up @@ -214,6 +216,15 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
this.attributes,
);
this.actions = this.withDefault(data.actions, this.actions);
// Normalize field-by-field rather than withDefault: a present-but-partial
// attack_protection object must not leave enumeration_protection undefined.
this.attackProtection = {
enumeration_protection: {
enabled:
data.attack_protection?.enumeration_protection?.enabled ??
this.attackProtection.enumeration_protection.enabled,
},
};
this.enterpriseSSO = this.withDefault(data.enterprise_sso, this.enterpriseSSO);
this.passkeySettings = this.withDefault(data.passkey_settings, this.passkeySettings);
this.passwordSettings = data.password_settings
Expand Down Expand Up @@ -252,6 +263,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
public __internal_toSnapshot(): UserSettingsJSONSnapshot {
return {
actions: this.actions,
attack_protection: this.attackProtection,
attributes: this.attributes,
passkey_settings: this.passkeySettings,
password_settings: this.passwordSettings,
Expand Down
31 changes: 31 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,37 @@ describe('SignIn', () => {
expect.anything(),
);
});

it('polls until firstFactorVerification status is transferable', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
client: null,
response: {
id: 'signin_123',
first_factor_verification: { status: 'unverified' },
},
})
.mockResolvedValueOnce({
client: null,
response: {
id: 'signin_123',
first_factor_verification: { status: 'transferable' },
},
});
BaseResource._fetch = mockFetch;

const signIn = new SignIn({ id: 'signin_123' } as any);
await signIn.__internal_future.emailLink.waitForVerification();

expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
path: '/client/sign_ins/signin_123',
}),
expect.anything(),
);
});
});

describe('sendPhoneCode', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './beforeUnloadTracker';
export * from './billing';
export * from '@clerk/shared/internal/clerk-js/completeSignUpFlow';
export * from '@clerk/shared/internal/clerk-js/navigateToNextStepSignUp';
export * from '@clerk/shared/internal/clerk-js/email';
export * from '@clerk/shared/internal/clerk-js/encoders';
export * from './errors';
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,10 @@ export const arSA: LocalizationResource = {
subtitleNewTab: 'ارجع إلى علامة التبويب المفتوحة حديثًا للمتابعة',
titleNewTab: 'تم تسجيل الدخول في علامة تبويب أخرى',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'استخدم رابط التحقق المرسل إلى بريدك الإلكتروني',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,10 @@ export const beBY: LocalizationResource = {
subtitleNewTab: 'Верніцеся на толькі што адчыненую ўкладку, каб працягнуць',
titleNewTab: 'Залогіньцеся на іншай укладцы',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Выкарыстоўвайце спасылку для пацвярджэння, адпраўленую на вашу электронную пошту',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,10 @@ export const bgBG: LocalizationResource = {
subtitleNewTab: 'Върнете се в новоотворения таб, за да продължите',
titleNewTab: 'Влезнали сте в друг таб',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Използвайте връзката за потвърждение, изпратена на вашия имейл',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,10 @@ export const bnIN: LocalizationResource = {
subtitleNewTab: 'চালিয়ে যেতে নতুন খোলা ট্যাবে ফিরে যান',
titleNewTab: 'অন্য ট্যাবে সাইন ইন হয়েছে',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'আপনার ইমেইলে পাঠানো যাচাইকরণ লিঙ্কটি ব্যবহার করুন',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,10 @@ export const caES: LocalizationResource = {
subtitleNewTab: 'Torna a la pestanya recentment oberta per continuar',
titleNewTab: "S'ha iniciat sessió en una altra pestanya",
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: "Utilitzeu l'enllaç de verificació enviat al vostre correu electrònic",
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/cs-CZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,10 @@ export const csCZ: LocalizationResource = {
subtitleNewTab: 'Vraťte se na nově otevřenou kartu pro pokračování',
titleNewTab: 'Přihlášeno na jiné kartě',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Použijte ověřovací odkaz zaslaný na váš e-mail',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/da-DK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,10 @@ export const daDK: LocalizationResource = {
subtitleNewTab: 'Vend tilbage til den nyligt åbnede fane for at fortsætte',
titleNewTab: 'Logget ind på anden fane',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Brug bekræftelseslinket, der er sendt til din e-mail',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/de-DE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,10 @@ export const deDE: LocalizationResource = {
subtitleNewTab: 'Kehren Sie zum neu geöffneten Tab zurück, um fortzufahren',
titleNewTab: 'In einem anderen Tab angemeldet',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Verwenden Sie den an Ihre E-Mail gesendeten Bestätigungslink',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/el-GR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,10 @@ export const elGR: LocalizationResource = {
subtitleNewTab: 'Επιστροφή στη νέα καρτέλα που άνοιξε για να συνεχίσετε',
titleNewTab: 'Έχετε συνδεθεί σε άλλη καρτέλα',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Χρησιμοποιήστε τον σύνδεσμο επαλήθευσης που στάλθηκε στο email σας',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/en-GB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,10 @@ export const enGB: LocalizationResource = {
subtitleNewTab: 'Return to the newly opened tab to continue',
titleNewTab: 'Signed in on other tab',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Use the verification link sent to your email',
Expand Down
Loading
Loading