diff --git a/.changeset/signin-protect-check-status-only-gate.md b/.changeset/signin-protect-check-status-only-gate.md
new file mode 100644
index 00000000000..0f4c58db97d
--- /dev/null
+++ b/.changeset/signin-protect-check-status-only-gate.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Fix sign-ins gated by Clerk Protect via `status: 'needs_protect_check'` without an inline `protectCheck` payload bouncing back to the flow start. `` now reloads the sign-in to fetch the challenge and only falls back to the flow start when no gate exists.
diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
index b71845be905..5c32cbcf63f 100644
--- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
+++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
@@ -24,7 +24,11 @@ import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart';
import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
import { useRouter } from '../../router';
import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams';
-import { isSignInPendingOAuthTransfer, resumeSignInAfterProtectCheck } from './handleProtectCheck';
+import {
+ isSignInPendingOAuthTransfer,
+ isSignInProtectGated,
+ resumeSignInAfterProtectCheck,
+} from './handleProtectCheck';
function SignInProtectCheckInternal(): JSX.Element | null {
const card = useCardState();
@@ -43,17 +47,37 @@ function SignInProtectCheckInternal(): JSX.Element | null {
// persist that a protect check existed at some point
const [everSawProtectCheck, setEverSawProtectCheck] = useState(!!signIn.protectCheck);
const didStartNoCheckFallbackRef = useRef(false);
+ // 'none' | 'pending' | 'done' — one bounded reload for a gate signalled by status alone
+ const [statusOnlyReload, setStatusOnlyReload] = useState<'none' | 'pending' | 'done'>('none');
if (signIn.protectCheck && !everSawProtectCheck) {
setEverSawProtectCheck(true);
}
useEffect(() => {
- if (!signIn.protectCheck && !everSawProtectCheck && !didStartNoCheckFallbackRef.current) {
+ if (signIn.protectCheck || everSawProtectCheck) {
+ return;
+ }
+ // A gate signalled by `status: 'needs_protect_check'` without an inline `protectCheck`
+ // payload is an in-progress, server-gated sign-in (e.g. gated on an OAuth callback
+ // exchange) — reload to fetch the challenge; bouncing to the flow start would discard
+ // the sign-in and force the user to restart.
+ if (isSignInProtectGated(signIn) && statusOnlyReload === 'none') {
+ setStatusOnlyReload('pending');
+ void signIn
+ .reload()
+ .catch(() => {})
+ .finally(() => setStatusOnlyReload('done'));
+ return;
+ }
+ if (statusOnlyReload === 'pending') {
+ return;
+ }
+ if (!didStartNoCheckFallbackRef.current) {
didStartNoCheckFallbackRef.current = true;
void navigateToFlowStart();
}
- }, [everSawProtectCheck, navigateToFlowStart, signIn.protectCheck]);
+ }, [everSawProtectCheck, navigateToFlowStart, signIn, statusOnlyReload]);
const { containerRef, isRunning, isWidgetVisible, hasError, retry } = useProtectCheckRunner({
getProtectCheck: () => signIn.protectCheck,
@@ -101,7 +125,8 @@ function SignInProtectCheckInternal(): JSX.Element | null {
// resolves" guarantee, nor keep a spinner next to the retry button.
const showSpinner = useSpinDelay(isRunning, { delay: 300 });
- // Stale/direct visit that never had a check: render nothing while the flow-start redirect
+ // No challenge payload yet (stale/direct visit, or the status-only reload above is in
+ // flight): render nothing while the flow-start redirect
// scheduled above kicks in, instead of flashing the card shell for one paint. Must stay
// below every hook call.
if (!signIn.protectCheck && !everSawProtectCheck) {
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
index d067fbb7396..c6522e3bcce 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
@@ -617,4 +617,46 @@ describe('SignInProtectCheck', () => {
await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('/sign-in'));
expect(mockExecute).not.toHaveBeenCalled();
});
+ it('reloads a status-only gate instead of bouncing to the flow start', async () => {
+ // `needs_protect_check` can be signalled without an inline `protectCheck` payload
+ // (e.g. a gate surfacing on an OAuth callback exchange); the challenge arrives on reload.
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ (fixtures.signIn as any).protectCheck = null;
+ const reload = vi.fn(() => {
+ (fixtures.signIn as any).protectCheck = {
+ status: 'pending',
+ token: 'challenge-token',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+ };
+ return Promise.resolve(fixtures.signIn as unknown as SignInResource);
+ });
+ (fixtures.signIn as any).reload = reload;
+ mockExecute.mockReturnValue(new Promise(() => {}));
+
+ const { findByText } = render(, { wrapper });
+
+ await waitFor(() => expect(reload).toHaveBeenCalled());
+ expect(await findByText(/verifying your request/i)).toBeInTheDocument();
+ expect(fixtures.router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the flow start when the reload still carries no challenge', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ (fixtures.signIn as any).protectCheck = null;
+ const reload = vi.fn(() => Promise.resolve(fixtures.signIn as unknown as SignInResource));
+ (fixtures.signIn as any).reload = reload;
+ fixtures.router.currentPath = '/sign-in/protect-check';
+ fixtures.router.fullPath = '/sign-in';
+ fixtures.router.indexPath = '/sign-in';
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(reload).toHaveBeenCalled());
+ await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('/sign-in'));
+ expect(mockExecute).not.toHaveBeenCalled();
+ });
});