diff --git a/.changeset/two-factor-newsession-null.md b/.changeset/two-factor-newsession-null.md new file mode 100644 index 0000000000..9e9d659174 --- /dev/null +++ b/.changeset/two-factor-newsession-null.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Document that `ctx.context.newSession` is `null` while a two-factor challenge is in flight. When a 2FA-enabled user signs in through a credential endpoint, the pending session is discarded and `newSession` is reset to `null` (no authenticated session exists until the second factor is verified). Server-side `after` hooks reading `ctx.context.newSession` must null-check it before accessing `newSession.user`. This clarifies the behavior change introduced in #9639. diff --git a/docs/content/docs/plugins/2fa.mdx b/docs/content/docs/plugins/2fa.mdx index ce6ed5099b..cb8201083b 100644 --- a/docs/content/docs/plugins/2fa.mdx +++ b/docs/content/docs/plugins/2fa.mdx @@ -120,6 +120,10 @@ By default, 2FA sign-in enforcement applies to the credential-based sign-in endp If your app needs to require 2FA for those sign-in methods, add custom hook handling for those endpoints and redirect users into your 2FA verification flow before treating the sign-in as complete. + + When a 2FA-enabled user signs in via a credential endpoint, the plugin issues a 2FA challenge instead of completing the sign-in. As part of this, the pending session is discarded and `ctx.context.newSession` is reset to `null` — there is no authenticated session until the user verifies the second factor. Server-side hooks (e.g. `after` hooks on the sign-in endpoints) that read `ctx.context.newSession` must null-check it before accessing `newSession.user`, otherwise they will throw while a 2FA challenge is in flight. + + You can handle this in the `onSuccess` callback or by providing a `onTwoFactorRedirect` callback in the plugin config. ```tsx diff --git a/packages/better-auth/src/plugins/two-factor/index.ts b/packages/better-auth/src/plugins/two-factor/index.ts index 037db4d21b..0caccf7093 100644 --- a/packages/better-auth/src/plugins/two-factor/index.ts +++ b/packages/better-auth/src/plugins/two-factor/index.ts @@ -469,7 +469,15 @@ export const twoFactor = (options?: O) => { } /** - * remove the session cookie. It's set by the sign in credential + * Remove the session cookie set by the credential sign-in. + * + * The credential handler already created a session and set + * `ctx.context.newSession`. Since 2FA is still pending, that + * session is deleted here and `newSession` is reset to `null` + * so downstream hooks don't observe a session that no longer + * exists. Hooks that read `ctx.context.newSession` after a + * sign-in must therefore null-check it: it is `null` while a + * 2FA challenge is in flight (no authenticated session yet). */ deleteSessionCookie(ctx, true); await ctx.context.internalAdapter.deleteSession(data.session.token);