[GH-ISSUE #1279] Allow 2FA activation/deactivation for social login users without password verification #25995

Open
opened 2026-04-17 16:22:26 -05:00 by GiteaMirror · 26 comments
Owner

Originally created by @helloworld9912 on GitHub (Jan 24, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/1279

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

Description

Currently, users who sign in through social providers (OAuth) cannot enable or disable Two-Factor Authentication (2FA) because the system requires password verification, which these users don't have.

Current Behavior

  • Users who sign in via social providers (Google, GitHub, etc.) can't activate 2FA
  • The current implementation requires password verification for:
    • Enabling 2FA (/two-factor/enable)
    • Disabling 2FA (/two-factor/disable)
    • Generating new backup codes (/two-factor/generate-backup-codes)

Expected Behavior

Users should be able to manage their 2FA settings regardless of their authentication method while maintaining security.

Proposed Solution

Implement an alternative verification method for social login users:

  1. Email OTP Verification:
    • When a social login user attempts to enable/disable 2FA:
      • Generate a one-time code
      • Send it to their verified email
      • Require this code for verification instead of password
// Example implementation
interface TwoFactorVerification {
  type: 'password' | 'email_otp';
  value: string;
}

Describe the solution you'd like

  1. When social login users try to enable/disable 2FA:

    • Generate a one-time email verification code
    • Send it to their verified email
    • Use this code instead of password verification
  2. Flow would be:
    User -> Request 2FA Change -> System Detects Social Login or passwordless login ->
    Send Email OTP -> User Verifies OTP -> Enable/Disable 2FA

Describe alternatives you've considered

I dont see any safe other alternatives

Additional context

No response

Originally created by @helloworld9912 on GitHub (Jan 24, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/1279 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. ## Description Currently, users who sign in through social providers (OAuth) cannot enable or disable Two-Factor Authentication (2FA) because the system requires password verification, which these users don't have. ## Current Behavior - Users who sign in via social providers (Google, GitHub, etc.) can't activate 2FA - The current implementation requires password verification for: - Enabling 2FA (`/two-factor/enable`) - Disabling 2FA (`/two-factor/disable`) - Generating new backup codes (`/two-factor/generate-backup-codes`) ## Expected Behavior Users should be able to manage their 2FA settings regardless of their authentication method while maintaining security. ## Proposed Solution Implement an alternative verification method for social login users: 1. **Email OTP Verification:** - When a social login user attempts to enable/disable 2FA: - Generate a one-time code - Send it to their verified email - Require this code for verification instead of password ```typescript // Example implementation interface TwoFactorVerification { type: 'password' | 'email_otp'; value: string; } ``` ### Describe the solution you'd like 1. When social login users try to enable/disable 2FA: - Generate a one-time email verification code - Send it to their verified email - Use this code instead of password verification 2. Flow would be: User -> Request 2FA Change -> System Detects Social Login or passwordless login -> Send Email OTP -> User Verifies OTP -> Enable/Disable 2FA ### Describe alternatives you've considered I dont see any safe other alternatives ### Additional context _No response_
GiteaMirror added the securityoauth labels 2026-04-17 16:22:26 -05:00
Author
Owner

@helloworld9912 commented on GitHub (Feb 15, 2025):

Any update on this ?
Best regards

<!-- gh-comment-id:2661067309 --> @helloworld9912 commented on GitHub (Feb 15, 2025): Any update on this ? Best regards
Author
Owner

@szwabodev commented on GitHub (Feb 19, 2025):

I am looking forward to this option too, but I am not sure why we're forced to actually provide any code/password to do these actions (I know they are important due to security reasons, but we should still have an option to choose and disable this verification). Could you also add configurable option to completely disable the need of providing a password/code for running these actions? Of course it doesn't need to be in current PR but would be nice to have this option added anyway.

<!-- gh-comment-id:2668210943 --> @szwabodev commented on GitHub (Feb 19, 2025): I am looking forward to this option too, but I am not sure why we're forced to actually provide any code/password to do these actions (I know they are important due to security reasons, but we should still have an option to choose and disable this verification). Could you also add configurable option to completely disable the need of providing a password/code for running these actions? Of course it doesn't need to be in current PR but would be nice to have this option added anyway.
Author
Owner

@helloworld9912 commented on GitHub (Feb 19, 2025):

I think a password or a security code is necessary to avoid account hijacking (exemple if a hacker get a valid cookie and enable / disable 2FA)

<!-- gh-comment-id:2668700516 --> @helloworld9912 commented on GitHub (Feb 19, 2025): I think a password or a security code is necessary to avoid account hijacking (exemple if a hacker get a valid cookie and enable / disable 2FA)
Author
Owner

@szwabodev commented on GitHub (Feb 19, 2025):

Yes I get the point why it's implemented, but I still think there should be opt-out flag if someone wants to keep it simpler. It should be up to developer to decide and not for the library to force it, it could just remain enabled by default.

<!-- gh-comment-id:2668719214 --> @szwabodev commented on GitHub (Feb 19, 2025): Yes I get the point why it's implemented, but I still think there should be opt-out flag if someone wants to keep it simpler. It should be up to developer to decide and not for the library to force it, it could just remain enabled by default.
Author
Owner

@helloworld9912 commented on GitHub (Feb 19, 2025):

yes I get it, that a bit the point of this issue: to get a way to have a more granular control on the activation/deactivation of 2FA

<!-- gh-comment-id:2669658025 --> @helloworld9912 commented on GitHub (Feb 19, 2025): yes I get it, that a bit the point of this issue: to get a way to have a more granular control on the activation/deactivation of 2FA
Author
Owner

@austinimperial commented on GitHub (Feb 28, 2025):

+1

<!-- gh-comment-id:2691593319 --> @austinimperial commented on GitHub (Feb 28, 2025): +1
Author
Owner

@samstoppani-triffin commented on GitHub (Mar 27, 2025):

+1

<!-- gh-comment-id:2758714461 --> @samstoppani-triffin commented on GitHub (Mar 27, 2025): +1
Author
Owner

@Lukem121 commented on GitHub (Apr 8, 2025):

+1

<!-- gh-comment-id:2785599589 --> @Lukem121 commented on GitHub (Apr 8, 2025): +1
Author
Owner

@csyedbilal commented on GitHub (Apr 9, 2025):

+1 very needful! @Bekacru requesting asap

<!-- gh-comment-id:2790645338 --> @csyedbilal commented on GitHub (Apr 9, 2025): +1 very needful! @Bekacru requesting asap
Author
Owner

@austinimperial commented on GitHub (Apr 9, 2025):

@csyedbilal I was able to do a workaround with the following flow. This would be for, say, a Google sign in.

  1. User enables 2FA (need enableMfa endpoint)
  2. User attempts sign in
  3. In Better-Auth after hook, intercept the process after successful login & session issued
  4. Cancel the session issuance by manually deleting the session, scrub the response object of all session data
  5. Redirect user to 2FA passcode page with JWT query param.
  6. User submits 2FA code
  7. Custom 2FA plugin verifies the 2FA code & JWT (JWT is proof of authentication with first factor, 2 min expiry)
  8. issue a session - Better-Auth API does not have an out-of-the-box "issueSession" function. It has ctx.context.internalAdapter.createSession(), which you can use, but you also need to set cookies. The cookie-setting functions are not available through the API, so I copied them directly from the repo.

Probably best to just wait until they add this feature, but fwiw, I was able to get this to work.

<!-- gh-comment-id:2791197685 --> @austinimperial commented on GitHub (Apr 9, 2025): @csyedbilal I was able to do a workaround with the following flow. This would be for, say, a Google sign in. 1. User enables 2FA (need enableMfa endpoint) 2. User attempts sign in 3. In Better-Auth after hook, intercept the process after successful login & session issued 4. Cancel the session issuance by manually deleting the session, scrub the response object of all session data 5. Redirect user to 2FA passcode page with JWT query param. 6. User submits 2FA code 7. Custom 2FA plugin verifies the 2FA code & JWT (JWT is proof of authentication with first factor, 2 min expiry) 8. issue a session - Better-Auth API does not have an out-of-the-box "issueSession" function. It has ctx.context.internalAdapter.createSession(), which you can use, but you also need to set cookies. The cookie-setting functions are not available through the API, so I copied them directly from the repo. Probably best to just wait until they add this feature, but fwiw, I was able to get this to work.
Author
Owner

@halindraprakoso commented on GitHub (May 3, 2025):

thanks @austinimperial

this is the barebone code that works for me (need further testing)

import { createRandomStringGenerator } from "@better-auth/utils/random";

const TWO_FACTOR_COOKIE_NAME = "two_factor";
export const generateRandomString = createRandomStringGenerator(
	"a-z",
	"0-9",
	"A-Z",
	"-_",
);

...

hooks: {
		after: createAuthMiddleware(async (ctx) => {
			const newSession = ctx.context.newSession;
			const isSignIn = !ctx.context.session && newSession;
			const isSocialSignIn = ctx.params?.id; // credentials sign in has no id

			if (isSignIn && isSocialSignIn && newSession.user.twoFactorEnabled) {
				ctx.setCookie(ctx.context.authCookies.sessionToken.name, "", {
					expires: new Date(0),
					path: "/",
				});

				// https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/plugins/two-factor/index.ts
				// ----------------------------------------------
				await ctx.context.internalAdapter.deleteSession(
					newSession.session.token,
				);
				const maxAge = 60 * 5; // 5 minutes
				const twoFactorCookie = ctx.context.createAuthCookie(
					TWO_FACTOR_COOKIE_NAME,
					{ maxAge },
				);
				const identifier = `2fa-${generateRandomString(20)}`;
				await ctx.context.internalAdapter.createVerificationValue({
					value: newSession.user.id,
					identifier,
					expiresAt: new Date(Date.now() + maxAge * 1000),
				});
				await ctx.setSignedCookie(
					twoFactorCookie.name,
					identifier,
					ctx.context.secret,
					twoFactorCookie.attributes,
				);
				// ----------------------------------------------

				return ctx.redirect(r.PAGE_verify);
			}
		}),
	},
<!-- gh-comment-id:2848359382 --> @halindraprakoso commented on GitHub (May 3, 2025): thanks @austinimperial this is the barebone code that works for me (need further testing) ```ts import { createRandomStringGenerator } from "@better-auth/utils/random"; const TWO_FACTOR_COOKIE_NAME = "two_factor"; export const generateRandomString = createRandomStringGenerator( "a-z", "0-9", "A-Z", "-_", ); ... hooks: { after: createAuthMiddleware(async (ctx) => { const newSession = ctx.context.newSession; const isSignIn = !ctx.context.session && newSession; const isSocialSignIn = ctx.params?.id; // credentials sign in has no id if (isSignIn && isSocialSignIn && newSession.user.twoFactorEnabled) { ctx.setCookie(ctx.context.authCookies.sessionToken.name, "", { expires: new Date(0), path: "/", }); // https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/plugins/two-factor/index.ts // ---------------------------------------------- await ctx.context.internalAdapter.deleteSession( newSession.session.token, ); const maxAge = 60 * 5; // 5 minutes const twoFactorCookie = ctx.context.createAuthCookie( TWO_FACTOR_COOKIE_NAME, { maxAge }, ); const identifier = `2fa-${generateRandomString(20)}`; await ctx.context.internalAdapter.createVerificationValue({ value: newSession.user.id, identifier, expiresAt: new Date(Date.now() + maxAge * 1000), }); await ctx.setSignedCookie( twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes, ); // ---------------------------------------------- return ctx.redirect(r.PAGE_verify); } }), }, ```
Author
Owner

@Sleepful commented on GitHub (May 25, 2025):

still very much needed

<!-- gh-comment-id:2908002010 --> @Sleepful commented on GitHub (May 25, 2025): still very much needed
Author
Owner

@FlawaCLV commented on GitHub (Jun 6, 2025):

Your issue is an architecture misconception in my opinion.

If you sign-in using Social Login, the 2FA is held by the account provider not your app. That's the entire point of using a social login. You are not responsible for the 2FA.

If you want to manage a 2FA to a previously signed-in user, you should ask him to create a password first thus will be able to authenticate both using Social & email/password/2FA.

<!-- gh-comment-id:2948709701 --> @FlawaCLV commented on GitHub (Jun 6, 2025): Your issue is an architecture misconception in my opinion. If you sign-in using Social Login, the 2FA is held by the account provider not your app. That's the entire point of using a social login. You are not responsible for the 2FA. If you want to manage a 2FA to a previously signed-in user, you should ask him to create a password first thus will be able to authenticate both using Social & email/password/2FA.
Author
Owner

@Sleepful commented on GitHub (Jun 9, 2025):

Your issue is an architecture misconception in my opinion.

Or maybe you don't understand the intent?

If you sign-in using Social Login, the 2FA is held by the account provider not your app. That's the entire point of using a social login. You are not responsible for the 2FA.

This would be enough IF there were a way to trigger a 2FA verification with the auth provider from within the Better-auth server, and then a way to verify that it completes successfully.

As far as I am aware, this is not currently possible.

So there needs to be a way of implementing 2FA for sensitive operations.

If you want to manage a 2FA to a previously signed-in user, you should ask him to create a password first thus will be able to authenticate both using Social & email/password/2FA.

It's unreasonable to ask for the user to create a password in order to use 2FA with a social sign on. I have many apps with 2FA, none implement this kind of flow.

The simplest 2FA is through email, which should already be present in social signon. It's a design flaw that email verification can't be used as 2FA method with social sign on.

<!-- gh-comment-id:2954371078 --> @Sleepful commented on GitHub (Jun 9, 2025): > Your issue is an architecture misconception in my opinion. Or maybe you don't understand the intent? > If you sign-in using Social Login, the 2FA is held by the account provider not your app. That's the entire point of using a social login. You are not responsible for the 2FA. This would be enough IF there were a way to trigger a 2FA verification with the auth provider from within the Better-auth server, and then a way to verify that it completes successfully. As far as I am aware, this is not currently possible. So there needs to be a way of implementing 2FA for sensitive operations. > If you want to manage a 2FA to a previously signed-in user, you should ask him to create a password first thus will be able to authenticate both using Social & email/password/2FA. It's unreasonable to ask for the user to create a password in order to use 2FA with a social sign on. I have many apps with 2FA, none implement this kind of flow. The simplest 2FA is through email, which should already be present in social signon. It's a design flaw that email verification can't be used as 2FA method with social sign on.
Author
Owner

@ilyaLibin commented on GitHub (Jun 9, 2025):

Would be really great to have custom conditions for two factor, for example users with admin permissions

<!-- gh-comment-id:2955767237 --> @ilyaLibin commented on GitHub (Jun 9, 2025): Would be really great to have custom conditions for two factor, for example users with admin permissions
Author
Owner

@diecodev commented on GitHub (Aug 12, 2025):

+1

<!-- gh-comment-id:3181356614 --> @diecodev commented on GitHub (Aug 12, 2025): +1
Author
Owner

@piercegearhart commented on GitHub (Aug 16, 2025):

+1

<!-- gh-comment-id:3193870059 --> @piercegearhart commented on GitHub (Aug 16, 2025): +1
Author
Owner

@michaelhays commented on GitHub (Aug 20, 2025):

Hey @Bekacru, any update on the feasibility of this? I saw you closed #1281, but wasn't sure why

<!-- gh-comment-id:3208346208 --> @michaelhays commented on GitHub (Aug 20, 2025): Hey @Bekacru, any update on the feasibility of this? I saw you closed #1281, but wasn't sure why
Author
Owner

@sudoramz commented on GitHub (Oct 20, 2025):

@halindraprakoso , this is a really decent workaround until this functionality hopefully gets baked into the actual APIs – Thanks!. Adopted this approach for my email-otp based sign in. I just added some functionality to remove the 2fa cookie after it's been used.

hooks: {
    after: createAuthMiddleware(async (ctx) => {
      if (ctx.path === '/two-factor/verify-totp' && ctx.method === 'POST') {
        const returned = ctx.context.returned
        const newSession = ctx.context.newSession

        if (!(returned instanceof APIError) && newSession) {
          const userId = newSession.user.id
          await db.deleteFrom('twoFactor')
            .where('twoFactor.userId', '=', userId)
            .execute()

          const maxAge = 0;
          const twoFactorCookie = ctx.context.createAuthCookie(
            TWO_FACTOR_COOKIE_NAME,
            { maxAge },
          );

          await ctx.setSignedCookie(
            twoFactorCookie.name,
            "",
            ctx.context.secret,
            {
              ...twoFactorCookie.attributes,
            }
          );
        }
      }

      if (ctx.path === "/sign-in/email-otp" && ctx.method === "POST") {
        const { step } = ctx.body
        const newSession = ctx.context.newSession;
        const isSignIn = !ctx.context.session && newSession;

        if (isSignIn && newSession?.user.twoFactorEnabled && step === 'email-otp') {
          ctx.setCookie(ctx.context.authCookies.sessionToken.name, "", {
            expires: new Date(0),
            path: "/",
          });

          await ctx.context.internalAdapter.deleteSession(
            newSession.session.token,
          );

          const maxAge = 60 * 5; // 5 minutes
          const twoFactorCookie = ctx.context.createAuthCookie(
            TWO_FACTOR_COOKIE_NAME,
            { maxAge },
          );

          const identifier = `2fa-${generateRandomString(20)}`;
          await ctx.context.internalAdapter.createVerificationValue({
            value: newSession.user.id,
            identifier,
            expiresAt: new Date(Date.now() + maxAge * 1000),
          });
          await ctx.setSignedCookie(
            twoFactorCookie.name,
            identifier,
            ctx.context.secret,
            twoFactorCookie.attributes,
          );
        }
      }
    }),
  },
<!-- gh-comment-id:3424051869 --> @sudoramz commented on GitHub (Oct 20, 2025): @halindraprakoso , this is a really decent workaround until this functionality hopefully gets baked into the actual APIs – Thanks!. Adopted this approach for my email-otp based sign in. I just added some functionality to remove the 2fa cookie after it's been used. ```ts hooks: { after: createAuthMiddleware(async (ctx) => { if (ctx.path === '/two-factor/verify-totp' && ctx.method === 'POST') { const returned = ctx.context.returned const newSession = ctx.context.newSession if (!(returned instanceof APIError) && newSession) { const userId = newSession.user.id await db.deleteFrom('twoFactor') .where('twoFactor.userId', '=', userId) .execute() const maxAge = 0; const twoFactorCookie = ctx.context.createAuthCookie( TWO_FACTOR_COOKIE_NAME, { maxAge }, ); await ctx.setSignedCookie( twoFactorCookie.name, "", ctx.context.secret, { ...twoFactorCookie.attributes, } ); } } if (ctx.path === "/sign-in/email-otp" && ctx.method === "POST") { const { step } = ctx.body const newSession = ctx.context.newSession; const isSignIn = !ctx.context.session && newSession; if (isSignIn && newSession?.user.twoFactorEnabled && step === 'email-otp') { ctx.setCookie(ctx.context.authCookies.sessionToken.name, "", { expires: new Date(0), path: "/", }); await ctx.context.internalAdapter.deleteSession( newSession.session.token, ); const maxAge = 60 * 5; // 5 minutes const twoFactorCookie = ctx.context.createAuthCookie( TWO_FACTOR_COOKIE_NAME, { maxAge }, ); const identifier = `2fa-${generateRandomString(20)}`; await ctx.context.internalAdapter.createVerificationValue({ value: newSession.user.id, identifier, expiresAt: new Date(Date.now() + maxAge * 1000), }); await ctx.setSignedCookie( twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes, ); } } }), }, ```
Author
Owner

@nicoandreoli2000 commented on GitHub (Jan 17, 2026):

Are there any updates? This is a must for better-auth developers

<!-- gh-comment-id:3764078712 --> @nicoandreoli2000 commented on GitHub (Jan 17, 2026): Are there any updates? This is a must for better-auth developers
Author
Owner

@Aqrare commented on GitHub (Jan 20, 2026):

+1

<!-- gh-comment-id:3771503763 --> @Aqrare commented on GitHub (Jan 20, 2026): +1
Author
Owner

@krzkz94 commented on GitHub (Jan 28, 2026):

Crazy that passwordless 2fa hasn't been implemented yet, it's a feature that is required for compliance, "2fa through providers or OTPs" does not fall under this, neither do social logins.

Some providers (Plaid) need an explicit 2fa step (passkey, TOTP, etc) or it won't be compliant.

<!-- gh-comment-id:3814087339 --> @krzkz94 commented on GitHub (Jan 28, 2026): Crazy that passwordless 2fa hasn't been implemented yet, it's a feature that is _required_ for compliance, "2fa through providers or OTPs" does not fall under this, neither do social logins. Some providers (Plaid) need an explicit 2fa step (passkey, TOTP, etc) or it won't be compliant.
Author
Owner

@nsmith520 commented on GitHub (Mar 17, 2026):

+1

<!-- gh-comment-id:4077111525 --> @nsmith520 commented on GitHub (Mar 17, 2026): +1
Author
Owner

@DemonMartin commented on GitHub (Mar 26, 2026):

Why the hell is this still not a thing?

<!-- gh-comment-id:4138758960 --> @DemonMartin commented on GitHub (Mar 26, 2026): Why the hell is this still not a thing?
Author
Owner

@EmilsValdmanis commented on GitHub (Apr 10, 2026):

Can this now be closed as the Two Factor plugin now has allowPasswordless option as of better-auth 1.6 (https://better-auth.com/docs/plugins/2fa#options)?

<!-- gh-comment-id:4225857287 --> @EmilsValdmanis commented on GitHub (Apr 10, 2026): Can this now be closed as the Two Factor plugin now has `allowPasswordless` option as of better-auth 1.6 (https://better-auth.com/docs/plugins/2fa#options)?
Author
Owner

@NEKOYASAN commented on GitHub (Apr 11, 2026):

Since #7243 only addressed the settings, this feature is not available in 1.6.0. However, at this rate, it looks like it will become available in 1.6.3 through #9122.

<!-- gh-comment-id:4230061725 --> @NEKOYASAN commented on GitHub (Apr 11, 2026): Since #7243 only addressed the settings, this feature is not available in 1.6.0. However, at this rate, it looks like it will become available in 1.6.3 through #9122.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#25995