Skip to main content
Articles

From setActive to finalize: Migrating Custom Auth Flows to Clerk Core 3

Author: Roy Anger
Published:

Clerk Core 3 changes how custom authentication flows handle session activation. The setActive() method is not removed — it still exists for switching sessions and organizations. What changed: authentication flows (sign-in and sign-up) now use finalize() instead of setActive(), and the beforeEmit callback is replaced by navigate everywhere.

This guide walks through every migration scenario with before/after code comparisons. Three rules cover the entire migration:

  1. Use finalize() when a new sign-in or sign-up flow creates the session.
  2. Keep setActive() for switching between existing sessions or changing the active organization.
  3. Replace beforeEmit with navigate wherever setActive() remains.
If your code does this...Use this in Core 3
Completes a sign-in or sign-up flowsignIn.finalize() / signUp.finalize()
Switches between existing sessionssetActive({ session, navigate })
Changes the active organizationsetActive({ organization, navigate })
Navigates after session changenavigate callback with decorateUrl

This article covers custom flow migration only. If you use Clerk's prebuilt components (<SignIn />, <SignUp />), the upgrade CLI handles the mechanical renames automatically. You should still verify your configuration after running it.

What setActive does in Clerk

In Clerk's session management model, setActive() serves three purposes:

  1. Setting the active session after sign-in or sign-up
  2. Switching between sessions in multi-session applications
  3. Changing the active organization in multi-org apps

Core 3 introduces decorateUrl inside the navigate callback. It transforms a path into the correct URL for the current environment. The developer pattern is: call decorateUrl('/path'), check if the result starts with http — if it does, use window.location.href for a full-page navigation; otherwise, use your framework's client-side router.

Core 2 setActive pattern

In Core 2, setActive() accepted a beforeEmit callback — a void side-effect with no access to session data or URL utilities:

// Core 2 pattern
await setActive({
  session: signIn.createdSessionId,
  beforeEmit: () => {
    router.push('/dashboard')
  },
})

Developers commonly used beforeEmit to trigger navigation during session activation. The callback ran as part of the activation process but had no access to the session object or environment-aware URL helpers.

What changed in Core 3

Three changes affect every custom authentication flow:

  1. beforeEmit is replaced by navigate
  2. A new finalize() method handles session activation for sign-in and sign-up flows
  3. The decorateUrl utility provides environment-aware URL transformation

beforeEmit is now navigate

The navigate callback receives { session, decorateUrl } instead of running as a void function. Per the Clerk docs, navigate is called just before the session and/or organization is set — giving you a window to trigger navigation before the new auth state propagates to client-side observers like useUser().

Before (Core 2):

await setActive({
  session: id,
  beforeEmit: () => {
    router.push('/dashboard')
  },
})

After (Core 3):

await setActive({
  session: id,
  navigate: async ({ session, decorateUrl }) => {
    const url = decorateUrl('/dashboard')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  },
})

The decorateUrl utility

decorateUrl transforms a path into the correct URL for the current environment. It may return the original path unchanged, or it may return an absolute URL when additional processing is required. It is safe to always call — it only modifies the URL when necessary.

The developer pattern is straightforward: always call decorateUrl(path), then check whether the result starts with http. If it does, use window.location.href for the redirect. Otherwise, use your framework's client-side router (e.g., router.push()).

Clerk logs a development warning if decorateUrl is not called when needed.

finalize() for authentication flows

finalize() is a new method on the signIn and signUp objects. It replaces the Core 2 pattern of calling setActive({ session: signIn.createdSessionId }) after a successful authentication flow.

// Core 2: extract session ID manually
await setActive({ session: signIn.createdSessionId })

// Core 3: finalize handles it
await signIn.finalize({
  navigate: async ({ session, decorateUrl }) => {
    const url = decorateUrl('/dashboard')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  },
})

No need to extract createdSessionIdfinalize() knows which session to activate.

When to use each:

  • finalize() — after completing a sign-in or sign-up flow
  • setActive() — for switching between existing sessions or changing the active organization

New hook shapes

The useSignIn() and useSignUp() hooks return completely different shapes in Core 3:

Core 2Core 3
{ isLoaded, signIn, setActive }{ signIn, errors, fetchStatus }
{ isLoaded, signUp, setActive }{ signUp, errors, fetchStatus }
  • No more isLoaded guard — the hook manages loading state internally via fetchStatus
  • errors provides structured field-level errors — access them via errors.fields.identifier, errors.fields.password, etc.
  • setActive is no longer on the hook — for session/org switching, get it from useClerk() or useOrganizationList()
  • The underlying resources changedSignInResource became SignInFutureResource, SignUpResource became SignUpFutureResource
Core 2 MethodCore 3 Method
signIn.create({ identifier, password })signIn.password({ emailAddress, password })
signIn.prepareFirstFactor() + attemptFirstFactor()signIn.emailCode.sendCode() + verifyCode()
signIn.prepareSecondFactor() + attemptSecondFactor()signIn.mfa.verifyTOTP() / verifyPhoneCode()
signIn.authenticateWithRedirect()signIn.sso()
signUp.create({ emailAddress, password })signUp.password({ emailAddress, password })
signUp.prepareEmailAddressVerification()signUp.verifications.sendEmailCode()
signUp.attemptEmailAddressVerification()signUp.verifications.verifyEmailCode()

Using the Clerk upgrade CLI

Before making manual changes, run the upgrade CLI. It performs a suite of AST-level codemods that handle mechanical renames automatically:

npx @clerk/upgrade

Or with your preferred package manager:

pnpm dlx @clerk/upgrade
yarn dlx @clerk/upgrade
bunx @clerk/upgrade

What the CLI automates

  • Package renames: @clerk/clerk-react to @clerk/react
  • beforeEmit to navigate property rename
  • Component consolidation: <SignedIn>, <SignedOut>, <Protect> to <Show>
  • appearance.layout to appearance.options
  • Hook imports: moves useSignIn/useSignUp to /legacy subpath

Use --dry-run to preview changes without writing files. Other flags include --dir to target a specific directory and --glob to narrow file selection.

What needs manual work

The CLI handles the rename, but not the rewrite. After running it, you still need to:

  • Rewrite navigate callback bodies to use { session, decorateUrl }
  • Migrate from setActive() to finalize() for auth completion
  • Add needs_client_trust status handling in sign-in flows
  • Configure taskUrls on <ClerkProvider> for session tasks
  • Migrate from legacy hook API to Core 3 hook API

Migrating custom sign-in flows

This is the most common migration. The examples below use Next.js with useRouter() from next/navigation. The Clerk API is identical across React, Next.js, TanStack Start, and other frameworks — only the router import differs.

Core 2: Custom sign-in with setActive

'use client'

import { useState } from 'react'
import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignInPage() {
  const { isLoaded, signIn, setActive } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const router = useRouter()

  if (!isLoaded) return null

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')

    try {
      const result = await signIn.create({
        identifier: email,
        password,
      })

      if (result.status === 'complete') {
        await setActive({
          session: result.createdSessionId,
          beforeEmit: () => {
            router.push('/dashboard')
          },
        })
      } else if (result.status === 'needs_second_factor') {
        // Handle MFA
      } else {
        console.error('Unexpected status:', result.status)
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign-in failed')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      {error && <p>{error}</p>}
      <button type="submit">Sign in</button>
    </form>
  )
}
'use client'

import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignInPage() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string

    const { error } = await signIn.password({
      emailAddress,
      password,
    })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: async ({ decorateUrl }) => {
          const url = decorateUrl('/dashboard')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    } else if (signIn.status === 'needs_second_factor') {
      // Handle MFA — see signIn.mfa.verifyTOTP() or signIn.mfa.verifyPhoneCode()
    } else if (signIn.status === 'needs_client_trust') {
      // Handle Client Trust verification — see the "Error handling" section
    } else {
      console.error('Sign-in attempt not complete:', signIn.status)
    }
  }

  return (
    <form action={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {errors?.fields?.identifier && <p>{errors.fields.identifier.message}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
        {errors?.fields?.password && <p>{errors.fields.password.message}</p>}
      </div>
      <button type="submit" disabled={fetchStatus === 'fetching'}>
        Sign in
      </button>
    </form>
  )
}

Key differences

The Core 3 sign-in migration changes several patterns:

  • No isLoaded guardfetchStatus replaces it. Use fetchStatus === 'fetching' to disable buttons during API calls.
  • No createdSessionId extractionfinalize() handles session activation internally.
  • signIn.password() replaces signIn.create() — methods are now action-specific instead of generic.
  • Error handling shifted from try/catch to return valuessignIn.password() returns { error } for programmatic logic. The errors object from the hook provides field-level errors for rendering.
  • needs_client_trust is new — this status appears when Client Trust is enabled and the user signs in with a password from an unfamiliar device. Per current docs, it only applies to password-based auth — passwordless methods are unaffected. If the user has already enabled MFA, their existing MFA method takes precedence and returns needs_second_factor instead. When triggered, verify the user via signIn.mfa.sendEmailCode() and signIn.mfa.verifyEmailCode({ code }). Check the Client Trust docs for the latest details on this flow.
  • Session tasks — if your app uses session tasks, configure taskUrls on <ClerkProvider> for automatic routing. See the session tasks section for details.

Migrating custom sign-up flows

The sign-up migration follows the same pattern. Method names changed, and finalize() replaces setActive().

Core 2: Custom sign-up with setActive

'use client'

import { useState } from 'react'
import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignUpPage() {
  const { isLoaded, signUp, setActive } = useSignUp()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const router = useRouter()

  if (!isLoaded) return null

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      await signUp.create({ emailAddress: email, password })
      await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
      setPendingVerification(true)
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  const handleVerify = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      const result = await signUp.attemptEmailAddressVerification({ code })
      if (result.status === 'complete') {
        await setActive({ session: signUp.createdSessionId })
        router.push('/dashboard')
      }
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  if (pendingVerification) {
    return (
      <form onSubmit={handleVerify}>
        <label htmlFor="code">Verification code</label>
        <input id="code" value={code} onChange={(e) => setCode(e.target.value)} />
        <button type="submit">Verify</button>
      </form>
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">Sign up</button>
    </form>
  )
}
'use client'

import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignUpPage() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string

    const { error } = await signUp.password({ emailAddress, password })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    await signUp.verifications.sendEmailCode()
  }

  const handleVerify = async (formData: FormData) => {
    const code = formData.get('code') as string

    const { error } = await signUp.verifications.verifyEmailCode({ code })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (signUp.status === 'complete') {
      await signUp.finalize({
        navigate: async ({ decorateUrl }) => {
          const url = decorateUrl('/dashboard')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    }
  }

  if (
    signUp.status === 'missing_requirements' &&
    signUp.unverifiedFields.includes('email_address') &&
    signUp.missingFields.length === 0
  ) {
    return (
      <form action={handleVerify}>
        <div>
          <label htmlFor="code">Verification code</label>
          <input id="code" name="code" type="text" />
          {errors?.fields?.code && <p>{errors.fields.code.message}</p>}
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Verify
        </button>
      </form>
    )
  }

  return (
    <>
      <form action={handleSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input id="email" name="email" type="email" />
          {errors?.fields?.emailAddress && <p>{errors.fields.emailAddress.message}</p>}
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input id="password" name="password" type="password" />
          {errors?.fields?.password && <p>{errors.fields.password.message}</p>}
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Sign up
        </button>
      </form>
      <div id="clerk-captcha" />
    </>
  )
}

Important

The <div id="clerk-captcha" /> element is required in Core 3 sign-up forms. Without it, sign-up requests will fail. Place it in your JSX where you want the CAPTCHA challenge to render.

Core 3 also introduces a signUpIfMissing option on signIn.create() for combined sign-in/sign-up flows that prevent account enumeration. See the sign-in-or-up custom flow docs for details.

Migrating OAuth and SSO flows

OAuth callback pages changed substantially in Core 3. The <AuthenticateWithRedirectCallback /> component is gone — you now build the callback page manually with finalize().

Important

Before using the OAuth examples below, ensure you have configured your social connection(s) in the Clerk Dashboard and set the NEXT_PUBLIC_CLERK_SIGN_IN_URL environment variable in your .env file (e.g., NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in). Without this variable, your app may default to the Account Portal sign-in page instead of your custom flow. See the OAuth connections custom flow docs for full setup details.

Core 2: OAuth with AuthenticateWithRedirectCallback

In Core 2, OAuth had two parts — an initiation page and a callback component:

Initiation:

'use client'

import { useSignIn } from '@clerk/nextjs'

export default function OAuthSignIn() {
  const { signIn } = useSignIn()

  const signInWithGoogle = () => {
    signIn.authenticateWithRedirect({
      strategy: 'oauth_google',
      redirectUrl: '/sso-callback',
      redirectUrlComplete: '/dashboard',
    })
  }

  return <button onClick={signInWithGoogle}>Sign in with Google</button>
}

Callback page:

import { AuthenticateWithRedirectCallback } from '@clerk/nextjs'

export default function SSOCallback() {
  return <AuthenticateWithRedirectCallback />
}

Core 3: OAuth with sso() and finalize

Core 3 replaces authenticateWithRedirect() with signIn.sso() and requires a manual callback page:

Initiation:

'use client'

import { OAuthStrategy } from '@clerk/shared/types'
import { useSignIn } from '@clerk/nextjs'

export default function OAuthSignIn() {
  const { signIn, errors } = useSignIn()

  const signInWith = async (strategy: OAuthStrategy) => {
    const { error } = await signIn.sso({
      strategy,
      redirectCallbackUrl: '/sso-callback',
      redirectUrl: '/sign-in/tasks',
    })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
    }
  }

  return (
    <>
      <button onClick={() => signInWith('oauth_google')}>Sign in with Google</button>
      {errors && <p>{JSON.stringify(errors, null, 2)}</p>}
    </>
  )
}

Note the parameter rename: redirectUrl + redirectUrlComplete became redirectCallbackUrl + redirectUrl.

Callback page:

'use client'

import { useClerk, useSignIn, useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

export default function SSOCallback() {
  const clerk = useClerk()
  const { signIn } = useSignIn()
  const { signUp } = useSignUp()
  const router = useRouter()
  const hasRun = useRef(false)

  const handleNavigate = async ({
    session,
    decorateUrl,
  }: {
    session: any
    decorateUrl: (url: string) => string
  }) => {
    if (session?.currentTask) {
      console.log(session?.currentTask)
      return
    }
    const url = decorateUrl('/')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  }

  useEffect(() => {
    ;(async () => {
      if (!clerk.loaded || hasRun.current) return
      hasRun.current = true

      // Happy path: sign-in completed by the OAuth provider
      if (signIn.status === 'complete') {
        await signIn.finalize({ navigate: handleNavigate })
        return
      }

      // Transfer: OAuth returned a sign-up, but user has an existing account
      if (signUp.isTransferable) {
        await signIn.create({ transfer: true })
        if (signIn.status === 'complete') {
          await signIn.finalize({ navigate: handleNavigate })
          return
        }
      }

      // Additional transfer scenarios exist — see Clerk's OAuth custom flows docs
      // for handling signIn.isTransferable, existingSession, and other edge cases

      router.push('/sign-in')
    })()
  }, [clerk, signIn, signUp])

  return (
    <div>
      <div id="clerk-captcha" />
    </div>
  )
}

The callback page handles multiple scenarios. The example above shows the happy path and one transfer case. See the OAuth connections custom flow docs for the full transfer matrix including signIn.isTransferable, existingSession, and needs_second_factor handling.

Enterprise SSO

Enterprise SSO uses the same signIn.sso() method with a strategy rename:

// Core 2
signIn.authenticateWithRedirect({
  strategy: 'saml',
  // ...
})

// Core 3
signIn.sso({
  strategy: 'enterprise_sso',
  identifier: email, // Email domain determines the enterprise connection
  redirectCallbackUrl: '/sso-callback',
  redirectUrl: '/sign-in/tasks',
})

The related property rename is user.samlAccounts to user.enterpriseAccounts.

Migrating multi-session applications

For session switching, setActive() remains the correct method — not finalize(). The key change is client.activeSessions renamed to client.sessions, and beforeEmit replaced by navigate.

'use client'

import { useClerk } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SessionSwitcher() {
  const { client, setActive, signOut, session: currentSession } = useClerk()
  const router = useRouter()

  const switchSession = async (sessionId: string) => {
    await setActive({
      session: sessionId,
      navigate: async ({ decorateUrl }) => {
        const url = decorateUrl('/')
        if (url.startsWith('http')) {
          window.location.href = url
        } else {
          router.push(url)
        }
      },
    })
  }

  return (
    <div>
      <h2>Active sessions</h2>
      <ul>
        {client.sessions.map((session) => (
          <li key={session.id}>
            {session.user?.primaryEmailAddress?.emailAddress}
            {session.id === currentSession?.id ? (
              <span> (current)</span>
            ) : (
              <button onClick={() => switchSession(session.id)}>Switch</button>
            )}
          </li>
        ))}
      </ul>
      <button onClick={() => signOut(currentSession?.id)}>Sign out current</button>
      <button onClick={() => signOut()}>Sign out all</button>
    </div>
  )
}

Key changes from Core 2:

  • client.activeSessions is now client.sessions
  • beforeEmit is now navigate with { session, decorateUrl }
  • setActive() comes from useClerk(), not from useSignIn()

Migrating organization switching

Organization switching uses setActive({ organization }) — the same pattern as Core 2, but with the navigate callback replacing beforeEmit.

Prebuilt component prop rename

If you use the <OrganizationSwitcher> component, one prop was renamed:

Before (Core 2):

<OrganizationSwitcher afterSwitchOrganizationUrl="/dashboard" />

After (Core 3):

<OrganizationSwitcher afterSelectOrganizationUrl="/dashboard" />

Custom organization switcher

For custom switchers using useOrganizationList():

'use client'

import { useAuth, useOrganizationList } from '@clerk/nextjs'

export default function OrgSwitcher() {
  const { isLoaded, setActive, userMemberships } = useOrganizationList({
    userMemberships: { pageSize: 5, keepPreviousData: true },
  })
  const { orgId } = useAuth()

  if (!isLoaded) return <p>Loading...</p>

  return (
    <div>
      <h2>Organizations</h2>
      <ul>
        {userMemberships?.data?.map((mem) => (
          <li key={mem.id}>
            {mem.organization.name} — {mem.role}
            {orgId !== mem.organization.id && (
              <button onClick={() => setActive({ organization: mem.organization.id })}>
                Switch
              </button>
            )}
          </li>
        ))}
      </ul>
      <button
        disabled={!userMemberships?.hasPreviousPage}
        onClick={() => userMemberships?.fetchPrevious?.()}
      >
        Previous
      </button>
      <button
        disabled={!userMemberships?.hasNextPage}
        onClick={() => userMemberships?.fetchNext?.()}
      >
        Next
      </button>
    </div>
  )
}

The navigate callback is optional for organization switching when you don't need to redirect after the switch. When provided, it follows the same { session, decorateUrl } pattern.

Handling session tasks after authentication

Session tasks are a Core 3 concept — pending requirements users must complete after authentication (e.g., choose-organization, reset-password, setup-mfa). Sessions with pending tasks enter a pending state and are treated as signed-out by default.

The simplest approach is configuring taskUrls on <ClerkProvider>. Clerk handles routing automatically — no manual navigate logic needed:

<ClerkProvider
  taskUrls={{
    'choose-organization': '/tasks/choose-organization',
    'reset-password': '/tasks/reset-password',
    'setup-mfa': '/tasks/setup-mfa',
  }}
>
  {children}
</ClerkProvider>

Clerk provides prebuilt components for each task type: <TaskSetupMFA />, <TaskResetPassword />, and <TaskChooseOrganization />. Mount them at the corresponding routes.

When taskUrls is configured, it overrides the navigate callback behavior — Clerk redirects to the task page automatically. This is the recommended approach for most applications.

When manual handling is needed

Only when building fully custom task UIs do you need to check session?.currentTask in the navigate callback and route based on session.currentTask.key. See the session tasks custom flow docs for the full implementation pattern.

Error handling and best practices

New error pattern

Core 3 authentication methods return { error: ClerkError | null } instead of throwing. Use the errors object from hooks for UI rendering and error from methods for programmatic logic:

const { signIn, errors, fetchStatus } = useSignIn()

const handleSubmit = async (formData: FormData) => {
  const { error } = await signIn.password({
    emailAddress: formData.get('email') as string,
    password: formData.get('password') as string,
  })

  // Programmatic: check the method's return value
  if (error) {
    console.error(error.code, error.message)
    return
  }
}

// UI: render field-level errors from the hook
{
  errors?.fields?.identifier && <p>{errors.fields.identifier.message}</p>
}
{
  errors?.fields?.password && <p>{errors.fields.password.message}</p>
}

Handle needs_client_trust

When Client Trust is enabled and a user signs in from an unfamiliar device, signIn.status returns needs_client_trust instead of complete. Per the current Client Trust docs, this only applies to password-based authentication — passwordless methods (email links, OTPs, passkeys, OAuth) are unaffected. If the user has already enabled MFA, their existing MFA method takes precedence and the status will be needs_second_factor instead. Check the Client Trust reference for the latest behavior details. Handle needs_client_trust by sending a verification code:

if (signIn.status === 'needs_client_trust') {
  // Find the email code factor
  const emailCodeFactor = signIn.supportedSecondFactors?.find(
    (factor) => factor.strategy === 'email_code',
  )
  if (emailCodeFactor) {
    await signIn.mfa.sendEmailCode()
    // Show verification code input
  }
}

After the user enters the code:

await signIn.mfa.verifyEmailCode({ code })

if (signIn.status === 'complete') {
  await signIn.finalize({
    navigate: async ({ decorateUrl }) => {
      const url = decorateUrl('/dashboard')
      if (url.startsWith('http')) {
        window.location.href = url
      } else {
        router.push(url)
      }
    },
  })
}

Always use decorateUrl

Always wrap destination URLs with decorateUrl in the navigate callback. Clerk logs a development warning if it's not called when needed. Handle both return types:

  • Absolute URL (starts with http) — use window.location.href
  • Relative path — use your framework's client-side router

Test in development first

Clerk's development instances support the same custom-flow APIs as production. Test your migrated flows in a development instance before deploying to catch configuration issues early.

Migration checklist

  1. Run npx @clerk/upgrade (or pnpm/yarn/bun equivalent)
  2. Verify beforeEmit to navigate renames applied by the CLI
  3. Identify setActive() calls that finish auth flows (sign-in/sign-up completion)
  4. Convert those calls to signIn.finalize() / signUp.finalize()
  5. Keep setActive() for session switching and organization switching
  6. Rewrite navigate callback bodies to use { session, decorateUrl }
  7. Add needs_client_trust status handling in sign-in flows
  8. Configure taskUrls on <ClerkProvider> for session tasks
  9. Test in Clerk's development environment before deploying

Quick reference: Core 2 vs. Core 3

API changes

Core 2Core 3
beforeEmitnavigate
setActive() after authfinalize()
client.activeSessionsclient.sessions
afterSwitchOrganizationUrlafterSelectOrganizationUrl
strategy: 'saml'strategy: 'enterprise_sso'
user.samlAccountsuser.enterpriseAccounts
authenticateWithRedirect()sso()
<AuthenticateWithRedirectCallback>Manual callback with finalize()

Method mapping

Core 2Core 3
signIn.create({ identifier, password })signIn.password({ emailAddress, password })
signIn.prepareFirstFactor() + attemptFirstFactor()signIn.emailCode.sendCode() + verifyCode()
signIn.prepareSecondFactor() + attemptSecondFactor()signIn.mfa.verifyTOTP() / verifyPhoneCode()
signIn.authenticateWithRedirect()signIn.sso()
signUp.create({ emailAddress, password })signUp.password({ emailAddress, password })
signUp.prepareEmailAddressVerification()signUp.verifications.sendEmailCode()
signUp.attemptEmailAddressVerification()signUp.verifications.verifyEmailCode()

Note

Other Core 3 changes to verify: @clerk/clerk-react renamed to @clerk/react. <SignedIn>/<SignedOut>/<Protect> consolidated into <Show>. appearance.layout renamed to appearance.options. getToken() now throws ClerkOfflineError when offline — wrap in try/catch, use ClerkOfflineError.is(error) from @clerk/react/errors. The Core 3 Upgrade Guide lists current runtime minimums (Node.js, Next.js, Expo, TanStack Start), and the custom-flow docs list SDK minimums (@clerk/react, @clerk/nextjs, etc.). Check those pages for the most current version requirements.