
From setActive to finalize: Migrating Custom Auth Flows to Clerk Core 3
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:
- Use
finalize()when a new sign-in or sign-up flow creates the session. - Keep
setActive()for switching between existing sessions or changing the active organization. - Replace
beforeEmitwithnavigatewhereversetActive()remains.
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:
- Setting the active session after sign-in or sign-up
- Switching between sessions in multi-session applications
- 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:
beforeEmitis replaced bynavigate- A new
finalize()method handles session activation for sign-in and sign-up flows - The
decorateUrlutility 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 createdSessionId — finalize() knows which session to activate.
When to use each:
finalize()— after completing a sign-in or sign-up flowsetActive()— 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:
- No more
isLoadedguard — the hook manages loading state internally viafetchStatus errorsprovides structured field-level errors — access them viaerrors.fields.identifier,errors.fields.password, etc.setActiveis no longer on the hook — for session/org switching, get it fromuseClerk()oruseOrganizationList()- The underlying resources changed —
SignInResourcebecameSignInFutureResource,SignUpResourcebecameSignUpFutureResource
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/upgradeOr with your preferred package manager:
pnpm dlx @clerk/upgrade
yarn dlx @clerk/upgrade
bunx @clerk/upgradeWhat the CLI automates
- Package renames:
@clerk/clerk-reactto@clerk/react beforeEmittonavigateproperty rename- Component consolidation:
<SignedIn>,<SignedOut>,<Protect>to<Show> appearance.layouttoappearance.options- Hook imports: moves
useSignIn/useSignUpto/legacysubpath
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
navigatecallback bodies to use{ session, decorateUrl } - Migrate from
setActive()tofinalize()for auth completion - Add
needs_client_truststatus handling in sign-in flows - Configure
taskUrlson<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
isLoadedguard —fetchStatusreplaces it. UsefetchStatus === 'fetching'to disable buttons during API calls. - No
createdSessionIdextraction —finalize()handles session activation internally. signIn.password()replacessignIn.create()— methods are now action-specific instead of generic.- Error handling shifted from try/catch to return values —
signIn.password()returns{ error }for programmatic logic. Theerrorsobject from the hook provides field-level errors for rendering. needs_client_trustis 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 returnsneeds_second_factorinstead. When triggered, verify the user viasignIn.mfa.sendEmailCode()andsignIn.mfa.verifyEmailCode({ code }). Check the Client Trust docs for the latest details on this flow.- Session tasks — if your app uses session tasks, configure
taskUrlson<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" />
</>
)
}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().
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.activeSessionsis nowclient.sessionsbeforeEmitis nownavigatewith{ session, decorateUrl }setActive()comes fromuseClerk(), not fromuseSignIn()
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.
Recommended: Configure taskUrls
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) — usewindow.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
- Run
npx @clerk/upgrade(or pnpm/yarn/bun equivalent) - Verify
beforeEmittonavigaterenames applied by the CLI - Identify
setActive()calls that finish auth flows (sign-in/sign-up completion) - Convert those calls to
signIn.finalize()/signUp.finalize() - Keep
setActive()for session switching and organization switching - Rewrite
navigatecallback bodies to use{ session, decorateUrl } - Add
needs_client_truststatus handling in sign-in flows - Configure
taskUrlson<ClerkProvider>for session tasks - Test in Clerk's development environment before deploying