After enableTwoFactor, verifying TOTP always returns INVALID_TWO_FACTOR_COOKIE. Does enableTwoFactor set a two-factor cookie? #2020

Closed
opened 2026-03-13 09:21:23 -05:00 by GiteaMirror · 11 comments
Owner

Originally created by @minhuy206 on GitHub (Sep 25, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Summary
After enabling 2FA via enableTwoFactor, my next call to verify TOTP (verify-totp) consistently fails with INVALID_TWO_FACTOR_COOKIE. I expected either:

enableTwoFactor to set a “pending 2FA” cookie (or update session cookie) required for the verify step, or
documentation clarifying that no cookie is set at enable time and what cookie/state the verify endpoint expects.

I also tried returnHeaders: true with enableTwoFactor, but the response contained no Set-Cookie headers.

Steps to reproduce:

Sign in (session cookies better-auth.session_token and better-auth.session_data are present).

Call enableTwoFactor:

const res = await betterAuth.api.enableTwoFactor({
  // payload: none/standard per docs
  returnHeaders: true
});
console.log('enableTwoFactor headers', res?.headers); // {} (empty)
// No Set-Cookie observed in Network tab.

Immediately call verify TOTP with the correct 6-digit code from the authenticator app:

const res = await betterAuth.api.verifyTOTP({
  body: { code: '<correct-6-digit>' },
  // forwarding incoming request headers from client -> server
  headers: fromNodeHeaders(req.headers),
  returnHeaders: true
});

Server responds:

401 Unauthorized
body: { code: 'INVALID_TWO_FACTOR_COOKIE', ... }

In the browser Network tab, the request to verify does include the existing session cookies, and the request is sent with credentials: 'include'.

Current vs. Expected behavior

Expected behavior:

Either enableTwoFactor returns a cookie/header that places the session into a “2FA pending” state, or official guidance on which cookie/state must be present for the verify step.

Subsequent call to verify TOTP should succeed when the correct 6-digit code is provided and cookies are forwarded.

Actual behavior

verifyTOTP returns INVALID_TWO_FACTOR_COOKIE (HTTP 401).

enableTwoFactor with returnHeaders: true returns an empty header object (no Set-Cookie).

What version of Better Auth are you using?

1.3.7

System info

{
  "system": {
    "platform": "macos",
  },
  "node": {
    "version": "v22.19.0",
    "env": "development"
  },
  "packageManager": {
    "name": "yarn",
    "version": "1.22.22"
  },
  "frameworks": NestJS,
  "databases": portgresql,
  "betterAuth": {
    "version": "1.3.7",
  }
}

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
      appName: this.configService.get<string>('NAME'),
      baseURL: this.configService.get<string>('BE_BASE_URL'),
      basePath: this.configService.get<string>('BE_BASE_PATH'),
      advanced: {
        defaultCookieAttributes: {
          secure: true,
          httpOnly: true,
          sameSite: 'none',
        },
        database: {
          generateId: false,
        },
      },
      trustedOrigins: this.configService
        .get<string>('ALLOW_ORIGINS')
        .split(',')
        .map((url) => url.trim()),
      database: prismaAdapter(this.prisma, {
        provider: 'postgresql',
      }),
      user: {
        additionalFields: {
          customerId: {
            type: 'string',
            required: false,
          },
        },
      },
      emailAndPassword: {
        enabled: true,
        password: {
          hash: this.hashService.hash,
          verify: this.hashService.compare,
        },
        sendResetPassword: async ({ user, url }) => {
          const token = url.split('/').pop().split('?')[0];
          const newURL = `${this.configService.get<string>('FE_BASE_URL')}/auth/reset-password?token=${token}`;
          await this.emailService.sendLinkEmail(
            user.email,
            newURL,
            TypeOfLink.reset_password,
          );
        },
      },
      emailVerification: {
        sendVerificationEmail: async ({ user, url }) => {
          await this.emailService.sendLinkEmail(
            user.email,
            url,
            TypeOfLink.email_verification,
          );
        },
        expiresIn: ms(
          this.configService.get<string>(
            'EMAIL_LINK_EXPIRATION',
          ) as ms.StringValue,
        ),
        sendOnSignUp: this.configService.get<boolean>(
          'SEND_EMAIL_VERIFICATION_ON_SIGN_UP',
        ),
        sendOnSignIn: this.configService.get<boolean>(
          'SEND_EMAIL_VERIFICATION_ON_SIGN_IN',
        ),
      },
      session: {
        cookieCache: {
          enabled: true,
          maxAge: 5 * 60,
        },
        session: {
          expiresIn: ms(
            this.configService.get<string>(
              'SESSION_EXPIRATION',
            ) as ms.StringValue,
          ),
        },
      },
      plugins: [
        twoFactor({
          otpOptions: {
            sendOTP: async (data) => {
              await this.emailService.sendOTPEmail(
                data.user.email,
                data.otp,
                TypeOfVerificationCode.sign_in,
              );
            },
            period: ms(
              this.configService.get<string>(
                'OTP_EXPIRATION',
              ) as ms.StringValue,
            ),
            digits: this.configService.get<number>('OTP_LENGTH'),
            allowedAttempts:
              this.configService.get<number>('OTP_ATTEMPTS_LIMIT'),
          },
          backupCodeOptions: {
            length: this.configService.get<number>('BACKUP_CODE_LENGTH'),
            amount: this.configService.get<number>('AMOUNT_OF_BACKUP_CODES'),
          },
          totpOptions: {
            digits: this.configService.get<number>('TOTP_DIGITS') as 6 | 8,
            period: ms(
              this.configService.get<string>('TOTP_PERIOD') as ms.StringValue,
            ),
          },
        }),
        passkey(),
        magicLink({
          sendMagicLink: async ({ email, url }) => {
            await this.emailService.sendLinkEmail(
              email,
              url,
              TypeOfLink.magic_link,
            );
          },
          expiresIn: ms(
            this.configService.get<string>(
              'EMAIL_LINK_EXPIRATION',
            ) as ms.StringValue,
          ),
        }),
      ],
    });
  }

Additional context

No response

Originally created by @minhuy206 on GitHub (Sep 25, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Summary After enabling 2FA via `enableTwoFactor`, my next call to verify TOTP (`verify-totp`) consistently fails with `INVALID_TWO_FACTOR_COOKIE`. I expected either: `enableTwoFactor` to set a “pending 2FA” cookie (or update session cookie) required for the verify step, or documentation clarifying that no cookie is set at enable time and what cookie/state the verify endpoint expects. I also tried `returnHeaders: true` with `enableTwoFactor`, but the response contained no `Set-Cookie` headers. Steps to reproduce: Sign in (session cookies better-auth.session_token and better-auth.session_data are present). Call enableTwoFactor: ```ts const res = await betterAuth.api.enableTwoFactor({ // payload: none/standard per docs returnHeaders: true }); console.log('enableTwoFactor headers', res?.headers); // {} (empty) // No Set-Cookie observed in Network tab. ``` Immediately call verify TOTP with the correct 6-digit code from the authenticator app: ```ts const res = await betterAuth.api.verifyTOTP({ body: { code: '<correct-6-digit>' }, // forwarding incoming request headers from client -> server headers: fromNodeHeaders(req.headers), returnHeaders: true }); ``` Server responds: ```json 401 Unauthorized body: { code: 'INVALID_TWO_FACTOR_COOKIE', ... } ``` In the browser Network tab, the request to verify does include the existing session cookies, and the request is sent with credentials: 'include'. ### Current vs. Expected behavior Expected behavior: Either `enableTwoFactor` returns a cookie/header that places the session into a “2FA pending” state, or official guidance on which cookie/state must be present for the verify step. Subsequent call to verify TOTP should succeed when the correct 6-digit code is provided and cookies are forwarded. Actual behavior `verifyTOTP` returns `INVALID_TWO_FACTOR_COOKIE` (HTTP 401). `enableTwoFactor` with `returnHeaders: true` returns an empty header object (no `Set-Cookie`). ### What version of Better Auth are you using? 1.3.7 ### System info ```bash { "system": { "platform": "macos", }, "node": { "version": "v22.19.0", "env": "development" }, "packageManager": { "name": "yarn", "version": "1.22.22" }, "frameworks": NestJS, "databases": portgresql, "betterAuth": { "version": "1.3.7", } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ appName: this.configService.get<string>('NAME'), baseURL: this.configService.get<string>('BE_BASE_URL'), basePath: this.configService.get<string>('BE_BASE_PATH'), advanced: { defaultCookieAttributes: { secure: true, httpOnly: true, sameSite: 'none', }, database: { generateId: false, }, }, trustedOrigins: this.configService .get<string>('ALLOW_ORIGINS') .split(',') .map((url) => url.trim()), database: prismaAdapter(this.prisma, { provider: 'postgresql', }), user: { additionalFields: { customerId: { type: 'string', required: false, }, }, }, emailAndPassword: { enabled: true, password: { hash: this.hashService.hash, verify: this.hashService.compare, }, sendResetPassword: async ({ user, url }) => { const token = url.split('/').pop().split('?')[0]; const newURL = `${this.configService.get<string>('FE_BASE_URL')}/auth/reset-password?token=${token}`; await this.emailService.sendLinkEmail( user.email, newURL, TypeOfLink.reset_password, ); }, }, emailVerification: { sendVerificationEmail: async ({ user, url }) => { await this.emailService.sendLinkEmail( user.email, url, TypeOfLink.email_verification, ); }, expiresIn: ms( this.configService.get<string>( 'EMAIL_LINK_EXPIRATION', ) as ms.StringValue, ), sendOnSignUp: this.configService.get<boolean>( 'SEND_EMAIL_VERIFICATION_ON_SIGN_UP', ), sendOnSignIn: this.configService.get<boolean>( 'SEND_EMAIL_VERIFICATION_ON_SIGN_IN', ), }, session: { cookieCache: { enabled: true, maxAge: 5 * 60, }, session: { expiresIn: ms( this.configService.get<string>( 'SESSION_EXPIRATION', ) as ms.StringValue, ), }, }, plugins: [ twoFactor({ otpOptions: { sendOTP: async (data) => { await this.emailService.sendOTPEmail( data.user.email, data.otp, TypeOfVerificationCode.sign_in, ); }, period: ms( this.configService.get<string>( 'OTP_EXPIRATION', ) as ms.StringValue, ), digits: this.configService.get<number>('OTP_LENGTH'), allowedAttempts: this.configService.get<number>('OTP_ATTEMPTS_LIMIT'), }, backupCodeOptions: { length: this.configService.get<number>('BACKUP_CODE_LENGTH'), amount: this.configService.get<number>('AMOUNT_OF_BACKUP_CODES'), }, totpOptions: { digits: this.configService.get<number>('TOTP_DIGITS') as 6 | 8, period: ms( this.configService.get<string>('TOTP_PERIOD') as ms.StringValue, ), }, }), passkey(), magicLink({ sendMagicLink: async ({ email, url }) => { await this.emailService.sendLinkEmail( email, url, TypeOfLink.magic_link, ); }, expiresIn: ms( this.configService.get<string>( 'EMAIL_LINK_EXPIRATION', ) as ms.StringValue, ), }), ], }); } ``` ### Additional context _No response_
Author
Owner

@dosubot[bot] commented on GitHub (Sep 25, 2025):

Hi @minhuy206! I'm Dosu and I’m helping the better-auth team.

No, enableTwoFactor does not set a two-factor cookie or session state. It only generates and returns the TOTP URI and backup codes; it does not place the session into a "pending 2FA" state or set any cookies required for the verification step. This is why you see no Set-Cookie header in the response, even with returnHeaders: true (source).

The verifyTOTP endpoint expects a specific two-factor cookie or session state to be present, which is typically set during a sign-in flow or a dedicated 2FA challenge/start endpoint—not during the enable step. If this cookie or state is missing, verifyTOTP will return INVALID_TWO_FACTOR_COOKIE (source).

To successfully verify TOTP, make sure you follow the flow where the two-factor cookie is set (usually after a sign-in that requires 2FA), then call the verification endpoint. The enable step is just for provisioning the secret and backup codes, not for starting a verification session.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 25, 2025): <!-- Greeting --> Hi @minhuy206! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> No, `enableTwoFactor` does not set a two-factor cookie or session state. It only generates and returns the TOTP URI and backup codes; it does not place the session into a "pending 2FA" state or set any cookies required for the verification step. This is why you see no `Set-Cookie` header in the response, even with `returnHeaders: true` ([source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/two-factor/index.ts)). The `verifyTOTP` endpoint expects a specific two-factor cookie or session state to be present, which is typically set during a sign-in flow or a dedicated 2FA challenge/start endpoint—not during the enable step. If this cookie or state is missing, `verifyTOTP` will return `INVALID_TWO_FACTOR_COOKIE` ([source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/two-factor/otp/index.ts)). To successfully verify TOTP, make sure you follow the flow where the two-factor cookie is set (usually after a sign-in that requires 2FA), then call the verification endpoint. The enable step is just for provisioning the secret and backup codes, not for starting a verification session. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/1752728c-cfbb-4c33-a826-44f24dff059a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/1752728c-cfbb-4c33-a826-44f24dff059a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/1752728c-cfbb-4c33-a826-44f24dff059a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/1752728c-cfbb-4c33-a826-44f24dff059a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/1752728c-cfbb-4c33-a826-44f24dff059a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/1752728c-cfbb-4c33-a826-44f24dff059a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/1752728c-cfbb-4c33-a826-44f24dff059a?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4905)
Author
Owner

@minhuy206 commented on GitHub (Sep 25, 2025):

Hi @dosu,

Per the docs, after enabling 2FA you must verify TOTP before twoFactorEnabled becomes true:

Note: twoFactorEnabled won’t be set to true until the user verifies their TOTP code… You can skip verification by setting skipVerificationOnEnable to true in your plugin config.
GitHub

However, verifyTOTP requires a two-factor cookie/session state, and enableTwoFactor doesn’t set any cookie. How should I obtain the required two-factor cookie/state so that I can call verifyTOTP and have twoFactorEnabled flip to true?

@minhuy206 commented on GitHub (Sep 25, 2025): Hi [@dosu,](dosu.dev) Per the docs, after enabling 2FA you must verify TOTP before twoFactorEnabled becomes true: > Note: twoFactorEnabled won’t be set to true until the user verifies their TOTP code… You can skip verification by setting skipVerificationOnEnable to true in your plugin config. > [GitHub](https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/plugins/2fa.mdx) > However, `verifyTOTP` requires a two-factor cookie/session state, and `enableTwoFactor` doesn’t set any cookie. How should I obtain the required two-factor cookie/state so that I can call `verifyTOTP` and have `twoFactorEnabled` flip to true?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 25, 2025):

This is a known quirk in Better Auth: the two-factor cookie required by verifyTOTP is not set by enableTwoFactor—it’s only set during sign-in flows that trigger 2FA (like /sign-in/email, /sign-in/username, or /sign-in/phone-number). So, after enabling 2FA, you need to trigger a sign-in (or a flow that requires 2FA) to get the cookie set, then call verifyTOTP with the correct code. That’s when twoFactorEnabled flips to true see source.

There’s no dedicated endpoint to “prime” the verification step right after enabling 2FA. If you want to skip verification, you can set skipVerificationOnEnable: true in your plugin config, but that’s not the default or recommended flow see source.

For social login or custom flows, some users manually set the two-factor cookie after session issuance as a workaround see example.

If you’re seeing INVALID_TWO_FACTOR_COOKIE, double-check your sign-in flow and cookie handling. The cookie must be set and sent with the verification request see source.

Let me know if you want help with a specific integration or workaround!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 25, 2025): <!-- Answer --> This is a known quirk in Better Auth: the two-factor cookie required by `verifyTOTP` is not set by `enableTwoFactor`—it’s only set during sign-in flows that trigger 2FA (like `/sign-in/email`, `/sign-in/username`, or `/sign-in/phone-number`). So, after enabling 2FA, you need to trigger a sign-in (or a flow that requires 2FA) to get the cookie set, then call `verifyTOTP` with the correct code. That’s when `twoFactorEnabled` flips to true [see source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/two-factor/index.ts). There’s no dedicated endpoint to “prime” the verification step right after enabling 2FA. If you want to skip verification, you can set `skipVerificationOnEnable: true` in your plugin config, but that’s not the default or recommended flow [see source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/two-factor/index.ts). For social login or custom flows, some users manually set the two-factor cookie after session issuance as a workaround [see example](https://github.com/better-auth/better-auth/issues/1279). If you’re seeing `INVALID_TWO_FACTOR_COOKIE`, double-check your sign-in flow and cookie handling. The cookie must be set and sent with the verification request [see source](https://github.com/better-auth/better-auth/issues/3217). Let me know if you want help with a specific integration or workaround! <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/3007740e-a952-4383-9913-d9e5193a8c27?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/3007740e-a952-4383-9913-d9e5193a8c27?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/3007740e-a952-4383-9913-d9e5193a8c27?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/3007740e-a952-4383-9913-d9e5193a8c27?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/3007740e-a952-4383-9913-d9e5193a8c27?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/3007740e-a952-4383-9913-d9e5193a8c27?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/3007740e-a952-4383-9913-d9e5193a8c27?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4905)
Author
Owner

@minhuy206 commented on GitHub (Sep 26, 2025):

Hi @dosu,

I’m a bit confused. You said I need to trigger sign-in again to get the two-factor cookie, but at that moment twoFactorEnabled is still false. Wouldn’t the user just sign in normally with no 2FA redirect? What exactly makes sign-in return twoFactorRedirect—is it the presence of a twoFactor.secret (or some other flag) rather than twoFactorEnabled? If so, could you confirm the exact condition?

@minhuy206 commented on GitHub (Sep 26, 2025): Hi [@dosu](dosu.dev), I’m a bit confused. You said I need to trigger sign-in again to get the two-factor cookie, but at that moment twoFactorEnabled is still false. Wouldn’t the user just sign in normally with no 2FA redirect? What exactly makes sign-in return twoFactorRedirect—is it the presence of a twoFactor.secret (or some other flag) rather than twoFactorEnabled? If so, could you confirm the exact condition?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 26, 2025):

The sign-in flow only triggers a twoFactorRedirect (and sets the two-factor cookie) if the user's twoFactorEnabled field is true in the session returned after sign-in. The presence of a twoFactor.secret or other setup flags does not cause the redirect—only the actual twoFactorEnabled flag does. If twoFactorEnabled is still false (which is the case right after enabling 2FA but before verifying), sign-in proceeds normally with no 2FA challenge or redirect. The redirect and cookie logic are strictly tied to twoFactorEnabled being true, which only happens after successful TOTP/OTP verification. See source

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 26, 2025): <!-- Answer --> The sign-in flow only triggers a twoFactorRedirect (and sets the two-factor cookie) if the user's `twoFactorEnabled` field is true in the session returned after sign-in. The presence of a `twoFactor.secret` or other setup flags does not cause the redirect—only the actual `twoFactorEnabled` flag does. If `twoFactorEnabled` is still false (which is the case right after enabling 2FA but before verifying), sign-in proceeds normally with no 2FA challenge or redirect. The redirect and cookie logic are strictly tied to `twoFactorEnabled` being true, which only happens after successful TOTP/OTP verification. [See source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/two-factor/index.ts) <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/6551a36f-2647-417b-b1a7-04968c7ff4d9?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/6551a36f-2647-417b-b1a7-04968c7ff4d9?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/6551a36f-2647-417b-b1a7-04968c7ff4d9?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/6551a36f-2647-417b-b1a7-04968c7ff4d9?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/6551a36f-2647-417b-b1a7-04968c7ff4d9?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/6551a36f-2647-417b-b1a7-04968c7ff4d9?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/6551a36f-2647-417b-b1a7-04968c7ff4d9?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4905)
Author
Owner

@minhuy206 commented on GitHub (Sep 26, 2025):

This creates a circular dependency: verifyTOTP needs the 2FA cookie, but the cookie/redirect only happens when twoFactorEnabled is already true—yet twoFactorEnabled only becomes true after verification. What is the official post-enable flow to obtain the required cookie for verification without skipVerificationOnEnable or custom cookie workarounds?

@minhuy206 commented on GitHub (Sep 26, 2025): This creates a circular dependency: verifyTOTP needs the 2FA cookie, but the cookie/redirect only happens when twoFactorEnabled is already true—yet twoFactorEnabled only becomes true after verification. What is the official post-enable flow to obtain the required cookie for verification without skipVerificationOnEnable or custom cookie workarounds?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 26, 2025):

There is currently no official Better Auth flow that sets the required two-factor cookie immediately after enabling 2FA—without using skipVerificationOnEnable or a custom workaround. The two-factor cookie is only set during a sign-in flow when twoFactorEnabled is already true, which only happens after successful TOTP verification. This creates the circular dependency you described. The recommended flow is to enable 2FA (provision secret/backup codes), then require the user to sign out and sign in again after verifying, at which point 2FA will be enforced. There is no built-in endpoint to "prime" the verification step or set the cookie/state needed for verifyTOTP right after enabling 2FA reference.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 26, 2025): <!-- Answer --> There is currently no official Better Auth flow that sets the required two-factor cookie immediately after enabling 2FA—without using skipVerificationOnEnable or a custom workaround. The two-factor cookie is only set during a sign-in flow when twoFactorEnabled is already true, which only happens after successful TOTP verification. This creates the circular dependency you described. The recommended flow is to enable 2FA (provision secret/backup codes), then require the user to sign out and sign in again after verifying, at which point 2FA will be enforced. There is no built-in endpoint to "prime" the verification step or set the cookie/state needed for verifyTOTP right after enabling 2FA [reference](https://github.com/better-auth/better-auth/issues/3217). <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/2b64c7aa-6042-4b08-884e-b6e8524f8a24?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/2b64c7aa-6042-4b08-884e-b6e8524f8a24?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/2b64c7aa-6042-4b08-884e-b6e8524f8a24?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/2b64c7aa-6042-4b08-884e-b6e8524f8a24?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/2b64c7aa-6042-4b08-884e-b6e8524f8a24?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/2b64c7aa-6042-4b08-884e-b6e8524f8a24?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/2b64c7aa-6042-4b08-884e-b6e8524f8a24?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4905)
Author
Owner

@dvanmali commented on GitHub (Sep 26, 2025):

Hi @minhuy206.

To use the auth.api.enableTwoFactor you need the password in the body and headers.

await auth.api.enableTwoFactor({
	returnHeaders: true,
	headers,
	body: {
		password: testUser.password,
	}
})

Headers should contain a cookie with the better-auth session token better-auth.session_token=xxx.yyy

@dvanmali commented on GitHub (Sep 26, 2025): Hi @minhuy206. To use the `auth.api.enableTwoFactor` you need the password in the body and headers. ```ts await auth.api.enableTwoFactor({ returnHeaders: true, headers, body: { password: testUser.password, } }) ``` Headers should contain a cookie with the better-auth session token `better-auth.session_token=xxx.yyy`
Author
Owner

@Bekacru commented on GitHub (Sep 30, 2025):

Please feel free to re-open if it's not fixed by @dvanmali suggestion

@Bekacru commented on GitHub (Sep 30, 2025): Please feel free to re-open if it's not fixed by @dvanmali suggestion
Author
Owner

@minhuy206 commented on GitHub (Oct 3, 2025):

Thanks @dvanmali and @Bekacru. I’m currently setting skipVerificationOnEnable: true to temporarily bypass it. I’ll double-check my implementation to make sure I’m including the headers (the better-auth.session_token cookie) when calling enableTwoFactor. Hopefully I just missed that step — @dvanmali’s guidance is a lifesaver!

@minhuy206 commented on GitHub (Oct 3, 2025): Thanks @dvanmali and @Bekacru. I’m currently setting skipVerificationOnEnable: true to temporarily bypass it. I’ll double-check my implementation to make sure I’m including the headers (the better-auth.session_token cookie) when calling enableTwoFactor. Hopefully I just missed that step — @dvanmali’s guidance is a lifesaver!
Author
Owner

@minhuy206 commented on GitHub (Oct 3, 2025):

 public async enableTwoFactor(body: EnableTwoFactorBodyDTO, req: Request) {
    try {
      return await (this.authInstance.api as any).enableTwoFactor({
        body,
        headers: fromNodeHeaders(req.headers),
        returnHeaders: true,
      });
    } catch (error) {
       ...
    }
  }

Hi @dvanmali,
I’m already including the headers when calling enableTwoFactor, but it still doesn’t work on my side. Am I missing any configuration?

@minhuy206 commented on GitHub (Oct 3, 2025): ```ts public async enableTwoFactor(body: EnableTwoFactorBodyDTO, req: Request) { try { return await (this.authInstance.api as any).enableTwoFactor({ body, headers: fromNodeHeaders(req.headers), returnHeaders: true, }); } catch (error) { ... } } ``` Hi @dvanmali, I’m already including the headers when calling enableTwoFactor, but it still doesn’t work on my side. Am I missing any configuration?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2020