From 01f7e2b0a78482b9a387b03bcc2b2dbc423bcfcb Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Wed, 15 Apr 2026 10:38:07 +0100 Subject: [PATCH] fix(two-factor): preserve enforcement on path-less endpoints Restore the pre-patch semantics for the after-hook matcher: a missing `context.path` must still match so virtual endpoints that mint a session are not silently exempt from 2FA. The `/two-factor/` and session-transition prefix checks now apply only when `path` is set. Also trim the `shouldEnforce` JSDoc; the behavioral detail lives in the Enforcement scope section of the 2FA docs. --- .../src/plugins/two-factor/index.ts | 17 +++++------ .../src/plugins/two-factor/types.ts | 28 ++++++------------- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/packages/better-auth/src/plugins/two-factor/index.ts b/packages/better-auth/src/plugins/two-factor/index.ts index 5ba49a24e9..0ec2210a6a 100644 --- a/packages/better-auth/src/plugins/two-factor/index.ts +++ b/packages/better-auth/src/plugins/two-factor/index.ts @@ -380,14 +380,15 @@ export const twoFactor = (options?: O) => { matcher(context) { if (context.context.newSession == null) return false; const path = context.path; - if (!path) return false; - if (path.startsWith("/two-factor/")) return false; - if ( - SESSION_TRANSITION_PATH_PREFIXES.some((prefix) => - path.startsWith(prefix), - ) - ) { - return false; + if (path) { + if (path.startsWith("/two-factor/")) return false; + if ( + SESSION_TRANSITION_PATH_PREFIXES.some((prefix) => + path.startsWith(prefix), + ) + ) { + return false; + } } return true; }, diff --git a/packages/better-auth/src/plugins/two-factor/types.ts b/packages/better-auth/src/plugins/two-factor/types.ts index 2936d449ee..69f8481174 100644 --- a/packages/better-auth/src/plugins/two-factor/types.ts +++ b/packages/better-auth/src/plugins/two-factor/types.ts @@ -66,29 +66,17 @@ export interface TwoFactorOptions { */ trustDeviceMaxAge?: number | undefined; /** - * Decides whether to challenge 2FA on a given sign-in. + * Decides whether to challenge 2FA on a given sign-in. Return `true` + * to challenge, `false` to skip. Setting this option replaces the + * built-in decision, including the passkey UV exemption. * - * Return `true` to challenge 2FA for this request; return `false` to - * skip. Setting this option replaces the built-in enforcement - * decision (including the passkey user-verification exemption). + * Same-user session rewrites (session refresh, `updateUser`) and + * session-transition endpoints (admin impersonation, multi-session + * switching) are never matched and cannot be overridden. * - * When omitted, 2FA is challenged on every sign-in that creates a - * new session, with one built-in exception: passkey sign-ins whose - * assertion confirmed user verification (UV) are not challenged, - * since a UV-verified passkey already satisfies MFA. + * The authenticating user is available at `ctx.context.newSession.user`. * - * Two guards run before this callback and cannot be overridden: - * same-user session rewrites (session refresh, `updateUser`) are - * never challenged, and session-transition endpoints (admin - * impersonation, multi-session switching) are never matched. - * - * The callback receives the endpoint context. The authenticating - * user is available at `ctx.context.newSession.user`. - * - * Use this option to skip 2FA on flows where the upstream provider - * is trusted to enforce it (for example, OAuth callbacks where the - * provider already required MFA), or to force a challenge on flows - * that the built-in logic would otherwise skip. + * @see {@link https://better-auth.com/docs/plugins/2fa#enforcement-scope} */ shouldEnforce?: | ((ctx: GenericEndpointContext) => boolean | Promise)