Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
296905f
feat(shared,ui,localizations): Show password confirmation attempt cou…
tmilewski Sep 23, 2026
ffee697
refactor(ui): Build password confirmation countdown on the password f…
tmilewski Sep 23, 2026
89a2ca7
chore(repo): Consolidate password confirmation changesets
tmilewski Sep 23, 2026
6b465aa
feat(localizations): Translate password confirmation attempt messages
tmilewski Sep 23, 2026
5188de5
chore(repo): Drop opt-in wording from password confirmation changeset
tmilewski Sep 24, 2026
dda5b3d
fix(localizations): Clarify Thai password confirmation limit message
tmilewski Sep 24, 2026
bbf2b01
fix(localizations): Use voseo in es-UY password confirmation limit me…
tmilewski Sep 25, 2026
df88382
Sign out when a password confirmation ends the session
tmilewski Sep 29, 2026
4ed044c
Only complete the sign-out for 422 password confirmation errors
tmilewski Sep 29, 2026
868ce3d
Drop unused ts-expect-error directives and raise the clerk.native.js …
tmilewski Sep 29, 2026
b2e558d
fix(clerk-js,ui): preserve the password attempt limit explanation aft…
tmilewski Oct 27, 2026
6786473
chore(ui): account for password confirmation messages in bundle budget
tmilewski Oct 27, 2026
bb40537
refactor(clerk-js): import shared local storage directly
tmilewski Oct 27, 2026
72f3f83
test(shared): colocate local storage coverage with implementation
tmilewski Oct 27, 2026
dcf6031
chore: fix native bundle budget and UserButton test timing
tmilewski Oct 28, 2026
63cea57
refactor: centralize password confirmation session-end handling
tmilewski Oct 29, 2026
d7c54fc
fix: carry password confirmation notice across sign-in origins
tmilewski Oct 29, 2026
a12f970
fix: clear stale password notice on session activation
tmilewski Oct 29, 2026
3960aee
chore(clerk-js): update bundle budgets for password notice handoff
tmilewski Oct 29, 2026
4f58354
fix(ui): consume session-ended notice once per mount
tmilewski Oct 30, 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
12 changes: 12 additions & 0 deletions .changeset/password-confirmation-attempts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@clerk/clerk-js': minor
'@clerk/localizations': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Show how many password attempts remain when a user enters the wrong current password while changing their password or during reverification, and explain that the session has ended once the attempts run out.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

- `ClerkAPIError.meta.remainingAttempts` holds the attempts left in the current session. `0` means the session has ended, and Clerk signs the user out of the current tab and notifies other tabs.
- Customize the messages with the `unstable__errors.password_confirmation_attempt_remaining`, `unstable__errors.password_confirmation_attempts_remaining` (receives `{{remainingAttempts}}`), and `unstable__errors.password_confirmation_session_ended` localization keys.
- After the attempt limit signs a user out, the next prebuilt sign-in form explains why their session ended, including after a page reload.
8 changes: 4 additions & 4 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "556KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "83KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" },
{ "path": "./dist/clerk.js", "maxSize": "558KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "85KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "128KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "324KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "82.5KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "83KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
143 changes: 143 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import {
EmailLinkErrorCodeStatus,
} from '@clerk/shared/error';
import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants';
import {
PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY,
SafeLocalStorage,
} from '@clerk/shared/internal/clerk-js/localStorage';
import {
PASSWORD_CONFIRMATION_NOTICE_TTL,
PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM,
} from '@clerk/shared/internal/clerk-js/passwordConfirmation';
import type {
ActiveSessionResource,
PendingSessionResource,
Expand Down Expand Up @@ -4391,6 +4399,141 @@ describe('Clerk singleton', () => {
});
});

describe('password confirmation sign-in redirects', () => {
beforeEach(() => window.localStorage.clear());
afterEach(() => window.localStorage.clear());

it('carries the notice to hosted sign-in and removes the source marker when redirecting', async () => {
const sut = new Clerk(productionPublishableKey);
await sut.load({
signInUrl: 'https://accounts.example.com/sign-in?existing=value#/start',
allowedRedirectOrigins: ['https://app.example.com'],
});
const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`;
const notice = `${sut.publishableKey}:${Date.now()}`;
SafeLocalStorage.setItem(key, notice, PASSWORD_CONFIRMATION_NOTICE_TTL);
const url = new URL(sut.buildSignInUrl({ redirectUrl: 'https://app.example.com/account' }));
expect(url.origin).toBe('https://accounts.example.com');
expect(url.searchParams.get(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(notice);
expect(url.searchParams.get('existing')).toBe('value');
expect(url.hash).toContain('/start');
expect(url.href).toContain(encodeURIComponent('https://app.example.com/account'));
expect(SafeLocalStorage.getItem(key, null)).toBe(notice);
const navigate = vi.spyOn(sut, 'navigate').mockResolvedValue(undefined);
await sut.redirectToSignIn({ redirectUrl: 'https://app.example.com/account' });
expect(navigate).toHaveBeenCalledWith(url.href);
expect(SafeLocalStorage.getItem(key, null)).toBeNull();
navigate.mockRestore();
});

it.each(['initialization', 'activation'])(
'does not replay a direct-builder notice after session %s',
async mode => {
const sut = new Clerk(productionPublishableKey);
const options = { signInUrl: 'https://accounts.example.com/sign-in' };
await sut.load(options);
const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`;
const otherKey = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:another_instance`;
const notice = `${sut.publishableKey}:${Date.now()}`;
SafeLocalStorage.setItem(key, notice, PASSWORD_CONFIRMATION_NOTICE_TTL);
SafeLocalStorage.setItem(otherKey, 'other-notice', PASSWORD_CONFIRMATION_NOTICE_TTL);

expect(new URL(sut.buildSignInUrl()).searchParams.get(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(notice);
expect(SafeLocalStorage.getItem(key, null)).toBe(notice);

const session = {
id: 'session_new',
status: 'active',
user: { id: 'user_1' },
touch: vi.fn().mockResolvedValue(undefined),
getToken: vi.fn().mockResolvedValue('token_new'),
lastActiveToken: { getRawString: () => 'token_new' },
};
const client = { sessions: [session], signedInSessions: [session], lastActiveSessionId: session.id };
let returningClerk = sut;
if (mode === 'initialization') {
mockClientFetch.mockResolvedValue(client);
returningClerk = new Clerk(productionPublishableKey);
await returningClerk.load(options);
} else {
sut.updateClient(client as any);
expect(SafeLocalStorage.getItem(key, null)).toBe(notice);
await sut.setActive({ session: session.id });
}

expect(returningClerk.session?.id).toBe(session.id);
expect(SafeLocalStorage.getItem(key, null)).toBeNull();
expect(SafeLocalStorage.getItem(otherKey, null)).toBe('other-notice');
await returningClerk.setActive({ session: null });
expect(
new URL(returningClerk.buildSignInUrl()).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM),
).toBe(false);
},
);

it.each(['expired', 'other-instance', 'missing'])('does not forward a %s notice', async kind => {
const sut = new Clerk(productionPublishableKey);
await sut.load({ signInUrl: 'https://accounts.example.com/sign-in' });
const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`;
if (kind !== 'missing') {
const notice =
kind === 'expired'
? `${sut.publishableKey}:${Date.now() - PASSWORD_CONFIRMATION_NOTICE_TTL}`
: `another_instance:${Date.now()}`;
SafeLocalStorage.setItem(key, notice, PASSWORD_CONFIRMATION_NOTICE_TTL);
}
expect(new URL(sut.buildSignInUrl()).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(false);
});
});

describe('__internal_handleSessionEnded', () => {
afterEach(() => {
(window as any).__internal_onAfterSetActive = null;
});

it.each([false, true])(
'preserves the notice and signs out when the response retains the active session: %s',
async retainsSession => {
const mockOnAfterSetActive = vi.fn().mockReturnValue(Promise.resolve());
(window as any).__internal_onAfterSetActive = mockOnAfterSetActive;
const eventBusSpy = vi.spyOn(eventBus, 'emit');
const mockSession = {
id: 'session_1',
status: 'active',
user: { id: 'user_1' },
lastActiveToken: { getRawString: () => 'token_1' },
};

const sut = new Clerk(productionPublishableKey);
sut.updateClient({ sessions: [mockSession], signedInSessions: [mockSession] } as any);
const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`;
const updateClient = sut.updateClient;
const updateClientSpy = vi.spyOn(sut, 'updateClient').mockImplementation(client => {
expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`));
updateClient(client);
});

await sut.__internal_handleSessionEnded({
sessions: retainsSession ? [mockSession] : [],
signedInSessions: retainsSession ? [mockSession] : [],
} as any);

expect(updateClientSpy).toHaveBeenCalledTimes(1);
expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`));
const now = Date.now();
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now + 5 * 60 * 1000 + 1);
expect(SafeLocalStorage.getItem(key, false)).toBe(false);
nowSpy.mockRestore();
updateClientSpy.mockRestore();

expect(sut.session).toBeNull();
expect(eventBusSpy).toHaveBeenCalledWith(events.UserSignOut, null);
expect(mockOnAfterSetActive).toHaveBeenCalled();
eventBusSpy.mockRestore();
},
);
});

describe('__internal_attemptToEnableEnvironmentSetting', () => {
afterEach(() => {
mockEnvironmentFetch.mockReset();
Expand Down
63 changes: 56 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ import {
ERROR_CODES,
} from '@clerk/shared/internal/clerk-js/constants';
import { hasMultipleEnterpriseConnections } from '@clerk/shared/internal/clerk-js/enterpriseSSOFactors';
import {
CLERK_ENVIRONMENT_STORAGE_ENTRY,
PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY,
SafeLocalStorage,
} from '@clerk/shared/internal/clerk-js/localStorage';
import {
isPasswordConfirmationNotice,
PASSWORD_CONFIRMATION_NOTICE_TTL,
PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM,
} from '@clerk/shared/internal/clerk-js/passwordConfirmation';
import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls';
import {
getTaskEndpoint,
Expand Down Expand Up @@ -174,7 +184,6 @@ import {
stripOrigin,
web3,
} from '../utils';
import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage';
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
import { AuthCookieService } from './auth/AuthCookieService';
import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat';
Expand Down Expand Up @@ -2271,7 +2280,17 @@ export class Clerk implements ClerkInterface {

const modifiedOptions = this.isSatellite ? this.#addSyncTriggerToRedirectOptions(options) : options;

return this.#buildUrl('signInUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues);
const signInUrl = this.#buildUrl('signInUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues);
const notice = SafeLocalStorage.getItem(
`${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`,
null,
);
if (!signInUrl || !isPasswordConfirmationNotice(notice, this.publishableKey)) {
return signInUrl;
}
const url = new URL(signInUrl, window.location.href);
url.searchParams.set(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, notice);
return url.href;
}

public buildSignUpUrl(options?: SignUpRedirectOptions): string {
Expand Down Expand Up @@ -2467,7 +2486,11 @@ export class Clerk implements ClerkInterface {

public redirectToSignIn = async (options?: SignInRedirectOptions): Promise<unknown> => {
if (inBrowser()) {
return this.navigate(this.buildSignInUrl(options));
const url = this.buildSignInUrl(options);
if (url && new URL(url, window.location.href).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)) {
SafeLocalStorage.removeItem(`${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`);
}
return this.navigate(url);
}
return;
};
Expand Down Expand Up @@ -3016,10 +3039,7 @@ export class Clerk implements ClerkInterface {
if (this.session) {
return;
}
if (opts.broadcast) {
eventBus.emit(events.UserSignOut, null);
}
return this.setActive({ session: null });
return this.#completeSignOut(opts);
} catch (err) {
// `/client` can fail with either a 401, a 403, 500 or network errors.
// 401 is already handled internally in our fetcher.
Expand All @@ -3033,6 +3053,31 @@ export class Clerk implements ClerkInterface {
}
};

/**
* Completes a sign-out that FAPI already performed, such as when a failed password
* confirmation ended the session and the piggybacked client no longer contains it.
*
* @internal
*/
public __internal_handleSessionEnded = (client?: ClientResource): Promise<void> => {
SafeLocalStorage.setItem(
`${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`,
`${this.publishableKey}:${Date.now()}`,
PASSWORD_CONFIRMATION_NOTICE_TTL,
);
if (client) {
this.updateClient(client);
}
return this.#completeSignOut({ broadcast: true });
};

#completeSignOut = (opts: { broadcast: boolean }): Promise<void> => {
if (opts.broadcast) {
eventBus.emit(events.UserSignOut, null);
}
return this.setActive({ session: null });
};

public __internal_handleUnauthenticatedDevBrowser = async (): Promise<void> => {
if (this.#authService) {
await this.#authService.handleUnauthenticatedDevBrowser();
Expand Down Expand Up @@ -3840,6 +3885,10 @@ export class Clerk implements ClerkInterface {
#updateAccessors = (session?: SignedInSessionResource | null, options?: { dangerouslySkipEmit?: boolean }) => {
const { session: newSession, organization, user } = this.#getAccessorsFromSession(session);

if (newSession?.status === 'active' && (this.session?.id !== newSession.id || this.session?.status !== 'active')) {
SafeLocalStorage.removeItem(`${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`);
}

this.session = newSession;
this.organization = organization;
this.user = user;
Expand Down
15 changes: 10 additions & 5 deletions packages/clerk-js/src/core/resources/Base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isValidBrowserOnline } from '@clerk/shared/browser';
import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error';
import { isPasswordConfirmationError } from '@clerk/shared/internal/clerk-js/passwordConfirmation';
import { isProductionFromPublishableKey } from '@clerk/shared/keys';
import type {
ClerkAPIErrorJSON,
Expand Down Expand Up @@ -128,15 +129,20 @@ export abstract class BaseResource {
}

const { payload, status, statusText, headers } = fapiResponse;

const errors = payload?.errors ?? [];
if (headers) {
const country = headers.get('x-country');

this.clerk.__internal_setCountry(country ? country.toLowerCase() : null);
}

// TODO: Link to Client payload piggybacking design document
if ((requestInit.method !== 'GET' || opts.forceUpdateClient) && !opts.skipUpdateClient) {
const shouldUpdateClient = (requestInit.method !== 'GET' || opts.forceUpdateClient) && !opts.skipUpdateClient;
if (status === 422 && isPasswordConfirmationError(errors[0]) && errors[0]?.meta?.remaining_attempts === 0) {

@alexcarpenter alexcarpenter Oct 30, 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.

Update: not a blocker. I read clerk_go#22414 at e7f09646 (not run). The API only sends remaining_attempts from password confirmation, so sign-in can't trigger this.

  • The limiter is only called from the reverification password step, change password and delete password.
  • remaining_attempts: 0 is only set after the session has ended.
  • Sign-in's AccountLockoutWarning has its own code, keeps the count in the message with no meta, and comes after the password error, in errors[1].

The client check itself still isn't scoped: a mocked 422 form_password_incorrect with remaining_attempts: 0 calls __internal_handleSessionEnded on any path. That only matters if another endpoint reuses this field later. Other uses were checked with GitHub code search, which may not be exhaustive.

await BaseResource.clerk.__internal_handleSessionEnded(
shouldUpdateClient ? getClientResourceFromPayload(payload) : undefined,
);
} else if (shouldUpdateClient) {
this._updateClient<J>(payload);
}

Expand All @@ -145,9 +151,8 @@ export abstract class BaseResource {
}

if (status >= 400) {
const errors = payload?.errors as ClerkAPIErrorJSON[];
const message = errors?.[0]?.long_message;
const code = errors?.[0]?.code;
const message = errors[0]?.long_message;
const code = errors[0]?.code;

// Handle 401 errors based on the specific error code:
// - dev_browser_unauthenticated: reset the dev browser token
Expand Down
Loading
Loading