[GH-ISSUE #6077] Two factor session cookies not working as intended in Next.js #19047

Closed
opened 2026-04-15 17:49:43 -05:00 by GiteaMirror · 7 comments
Owner

Originally created by @SanderVHGD on GitHub (Nov 18, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6077

Originally assigned to: @gustavovalverde, @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create a Next.js application with the better-auth 2fa ans nextCookies plugins.
  2. Set up 2fa using server actions.
  3. Create a server action to enable 2fa
const { totpURI, backupCodes } = await auth.api.enableTwoFactor({
  body: {
    password,
  },
  headers: await headers(),
});
  1. Create a server action to verify totp
const { user } = await auth.api.verifyTOTP({
  body: {
    code: token,
    trustDevice,
  },
  headers: await headers(),
});
  1. Create a server action to disable 2fa
await auth.api.disableTwoFactor({
  body: {
    password,
  },
  headers: await headers(),
});
  1. Create a page that gets the session and redirects to sign in if the session is null
export default async function Page(): Promise<JSX.Element> {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    redirect("/sign-in");
  }

  ...
}
  1. Go through the 2fa setup flow:
    a. Enable 2fa
    b. Verify TOTP -> redirect to /sign-in triggered (session cookies not working as intended)
    c. Disable 2fa -> redirect to /sign-in triggered (session cookies not working as intended)

Current vs. Expected behavior

Current behavior

  1. auth.api.enableTwoFactor is called.
    two_factor_enabled remains false in the database and the totp and backupCodes are returned.

  2. auth.api.verifyTOTP is called.
    Since two_factor_enabled is false in the database the following code is executed in better-auth:

const newSession = await ctx.context.internalAdapter
	.createSession(user.id, false, session.session)
	.catch((e) => {
		throw e;
	});

await ctx.context.internalAdapter.deleteSession(session.session.token);
await setSessionCookie(ctx, {
	session: newSession,
	user: updatedUser,
});

When the auth cookie is deleted by deleteSession, Next.js triggers a page refresh. This triggers getSession in the Page which returns null, resulting in a redirect to /sign-in.
Then the new cookie get set by setSessionCookie and Next.js triggers a new page refresh. This triggers getSession again and only now the user is returned since there is an auth cookie.

  1. auth.api.disableTwoFactor is called.
    The following code is executed in better-auth:
onst newSession = await ctx.context.internalAdapter.createSession(
	updatedUser.id,
	false,
	ctx.context.session.session,
);
await setSessionCookie(ctx, {
	session: newSession,
	user: updatedUser,
});
await ctx.context.internalAdapter.deleteSession(
	ctx.context.session.session.token,
);

The new cookie is set by setSessionCookie, Next.js triggers a page refresh. This triggers getSession and the user is returned since there is an auth cookie.
Then the auth cookie is deleted by deleteSession, Next.js triggers a page refresh. This triggers getSession in the Page which returns null, resulting in a redirect to /sign-in.

Expteced behavior

  1. auth.api.enableTwoFactor is called. two_factor_enabled remains false in the database and the totp and backupCodes are returned.

  2. auth.api.verifyTOTP is called.
    The new auth cookie overwrites the existing one so that if Next.js triggers a new page refresh, getSession is called and the user is returned since there is an auth cookie. Meaning there is no short period where getSession is null, avoiding the redirect to /sign-in.

  3. auth.api.disableTwoFactor is called.
    The new auth cookie overwrites the existing one so that if Next.js triggers a new page refresh, getSession is called and the user is returned since there is an auth cookie. Meaning there is no short period where getSession is null, avoiding the redirect to /sign-in.

What version of Better Auth are you using?

1.3.34

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 23.6.0: Wed Nov 18 21:04:50 PDT 2025; root:xnu-10063.141.1.707.11~1/RELEASE_ARM64_T6000",
    "release": "23.6.0",
    "cpuCount": 10,
    "cpuModel": "Apple M1 Pro",
    "totalMemory": "16.00 GB",
    "freeMemory": "6.58 GB"
  },
  "node": {
    "version": "v24.11.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.6.1"
  },
  "frameworks": null,
  "databases": null,
  "betterAuth": {
    "version": "Unknown",
    "config": null
  }
}

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

Package, Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  emailAndPassword: {  
    enabled: true
  },
  plugins: [
    twoFactor({
      schema: {
        twoFactor: {
          modelName: "twoFactorRegistration",
        },
      },
    }),
    nextCookies(),
  ],
});

Additional context

This issue is due to how Next.js handles cookies in server actions. See Next.js cookies documentation.

After you set or delete a cookie in a Server Action, Next.js re-renders the current page and its layouts on the server so the UI reflects the new cookie value.

The 2fa plugin verifyTotp and disableTwoFactor delete and set cookies sequentially causing multiple page refreshes and empty getSession responses.

A potential solution that could be tested is to overwrite the existing cookie instead of deleting and setting sequentially resulting in only one page refresh with getSession always returning the correct cookie value.

Originally created by @SanderVHGD on GitHub (Nov 18, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6077 Originally assigned to: @gustavovalverde, @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create a Next.js application with the better-auth 2fa ans nextCookies plugins. 2. Set up 2fa using server actions. 3. Create a server action to enable 2fa ```TypeScript const { totpURI, backupCodes } = await auth.api.enableTwoFactor({ body: { password, }, headers: await headers(), }); ``` 4. Create a server action to verify totp ```TypeScript const { user } = await auth.api.verifyTOTP({ body: { code: token, trustDevice, }, headers: await headers(), }); ``` 5. Create a server action to disable 2fa ```TypeScript await auth.api.disableTwoFactor({ body: { password, }, headers: await headers(), }); ``` 6. Create a page that gets the session and redirects to sign in if the session is null ```TypeScript export default async function Page(): Promise<JSX.Element> { const session = await auth.api.getSession({ headers: await headers(), }); if (!session) { redirect("/sign-in"); } ... } ``` 7. Go through the 2fa setup flow: a. Enable 2fa b. Verify TOTP -> redirect to `/sign-in` triggered (session cookies not working as intended) c. Disable 2fa -> redirect to `/sign-in` triggered (session cookies not working as intended) ### Current vs. Expected behavior **Current behavior** 1. `auth.api.enableTwoFactor` is called. `two_factor_enabled` remains false in the database and the `totp` and `backupCodes` are returned. 2. `auth.api.verifyTOTP` is called. Since `two_factor_enabled` is false in the database the following code is executed in better-auth: ```TypeScript const newSession = await ctx.context.internalAdapter .createSession(user.id, false, session.session) .catch((e) => { throw e; }); await ctx.context.internalAdapter.deleteSession(session.session.token); await setSessionCookie(ctx, { session: newSession, user: updatedUser, }); ``` When the auth cookie is deleted by `deleteSession`, Next.js triggers a page refresh. This triggers `getSession` in the Page which returns null, resulting in a redirect to `/sign-in`. Then the new cookie get set by `setSessionCookie` and Next.js triggers a new page refresh. This triggers `getSession` again and only now the user is returned since there is an auth cookie. 3. `auth.api.disableTwoFactor` is called. The following code is executed in better-auth: ```TypeScript onst newSession = await ctx.context.internalAdapter.createSession( updatedUser.id, false, ctx.context.session.session, ); await setSessionCookie(ctx, { session: newSession, user: updatedUser, }); await ctx.context.internalAdapter.deleteSession( ctx.context.session.session.token, ); ``` The new cookie is set by `setSessionCookie`, Next.js triggers a page refresh. This triggers `getSession` and the user is returned since there is an auth cookie. Then the auth cookie is deleted by `deleteSession`, Next.js triggers a page refresh. This triggers `getSession` in the Page which returns null, resulting in a redirect to `/sign-in`. **Expteced behavior** 1. `auth.api.enableTwoFactor` is called. `two_factor_enabled` remains false in the database and the `totp` and `backupCodes` are returned. 2. `auth.api.verifyTOTP` is called. The new auth cookie overwrites the existing one so that if Next.js triggers a new page refresh, `getSession` is called and the user is returned since there is an auth cookie. **Meaning there is no short period where `getSession` is null, avoiding the redirect to `/sign-in`.** 3. `auth.api.disableTwoFactor` is called. The new auth cookie overwrites the existing one so that if Next.js triggers a new page refresh, `getSession` is called and the user is returned since there is an auth cookie. **Meaning there is no short period where `getSession` is null, avoiding the redirect to `/sign-in`.** ### What version of Better Auth are you using? 1.3.34 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 23.6.0: Wed Nov 18 21:04:50 PDT 2025; root:xnu-10063.141.1.707.11~1/RELEASE_ARM64_T6000", "release": "23.6.0", "cpuCount": 10, "cpuModel": "Apple M1 Pro", "totalMemory": "16.00 GB", "freeMemory": "6.58 GB" }, "node": { "version": "v24.11.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.6.1" }, "frameworks": null, "databases": null, "betterAuth": { "version": "Unknown", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Package, Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ emailAndPassword: { enabled: true }, plugins: [ twoFactor({ schema: { twoFactor: { modelName: "twoFactorRegistration", }, }, }), nextCookies(), ], }); ``` ### Additional context This issue is due to how Next.js handles cookies in server actions. See [Next.js cookies documentation](https://nextjs.org/docs/app/api-reference/functions/cookies#understanding-cookie-behavior-in-server-actions). > After you set or delete a cookie in a Server Action, Next.js re-renders the current page and its layouts on the server so the UI reflects the new cookie value. The 2fa plugin `verifyTotp` and `disableTwoFactor` delete and set cookies sequentially causing multiple page refreshes and empty `getSession` responses. A potential solution that could be tested is to overwrite the existing cookie instead of deleting and setting sequentially resulting in only one page refresh with `getSession` always returning the correct cookie value.
GiteaMirror added the platformsecuritybug labels 2026-04-15 17:49:44 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Nov 18, 2025):

It sounds like the root problem is that the two‐factor endpoints (verifyTOTP and disableTwoFactor) are first deleting the existing cookie and then setting a new one. Because Next.js re-renders the page immediately after a cookie modification, you end up with a short window where getSession returns null and triggers an unwanted redirect. A possible next step is to modify the logic so that you overwrite the existing cookie in one step rather than deleting and then setting it sequentially. This should help avoid that short time window where no valid session exists, which in turn prevents the extra page refresh and the incorrect sign‐out behavior [1].

Have you had a chance to test a combined overwrite approach, or would you need guidance on how to implement that change? Tag @better-auth-agent if you would like an answer from the Agent.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3549412523 --> @better-auth-agent[bot] commented on GitHub (Nov 18, 2025): It sounds like the root problem is that the two‐factor endpoints (verifyTOTP and disableTwoFactor) are first deleting the existing cookie and then setting a new one. Because Next.js re-renders the page immediately after a cookie modification, you end up with a short window where getSession returns null and triggers an unwanted redirect. A possible next step is to modify the logic so that you overwrite the existing cookie in one step rather than deleting and then setting it sequentially. This should help avoid that short time window where no valid session exists, which in turn prevents the extra page refresh and the incorrect sign‐out behavior [[1]](https://github.com/better-auth/better-auth/issues/2475). Have you had a chance to test a combined overwrite approach, or would you need guidance on how to implement that change? Tag @better-auth-agent if you would like an answer from the Agent. <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@dosubot[bot] commented on GitHub (Nov 18, 2025):

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

You’ve pinpointed the root cause: in better-auth’s 2FA flows, the code deletes the old session cookie and then sets a new one, which triggers two page refreshes in Next.js server actions. This causes a brief window where getSession returns null, leading to unwanted redirects to /sign-in—exactly as you described.

The good news: you can avoid this by skipping the explicit cookie deletion and just calling setSessionCookie to overwrite the existing cookie. Both the better-auth core and the nextCookies plugin support atomic overwriting—setting a cookie with the same name and path will replace the old value in a single operation, resulting in only one page refresh and no null session window. This matches the recommended approach in the Next.js cookies docs and aligns with how the Next.js cookies API works in server actions.

To fix the issue, update your 2FA flows (especially in verifyTOTP and disableTwoFactor) to remove the explicit deleteSession or deleteSessionCookie call before setting the new session cookie. Just create the new session and call setSessionCookie with the new session and user. This will ensure the session cookie is replaced atomically, preventing the temporary null session and unwanted redirect.

References:

This change should resolve the session flicker and redirect problem in your Next.js 2FA flows.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3549443508 --> @dosubot[bot] commented on GitHub (Nov 18, 2025): <!-- Greeting --> Hi @SanderVHGD! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> You’ve pinpointed the root cause: in better-auth’s 2FA flows, the code deletes the old session cookie and then sets a new one, which triggers two page refreshes in Next.js server actions. This causes a brief window where `getSession` returns null, leading to unwanted redirects to `/sign-in`—exactly as you described. The good news: you can avoid this by skipping the explicit cookie deletion and just calling `setSessionCookie` to overwrite the existing cookie. Both the better-auth core and the `nextCookies` plugin support atomic overwriting—setting a cookie with the same name and path will replace the old value in a single operation, resulting in only one page refresh and no null session window. This matches the recommended approach in the Next.js cookies docs and aligns with how the Next.js `cookies` API works in server actions. To fix the issue, update your 2FA flows (especially in `verifyTOTP` and `disableTwoFactor`) to remove the explicit `deleteSession` or `deleteSessionCookie` call before setting the new session cookie. Just create the new session and call `setSessionCookie` with the new session and user. This will ensure the session cookie is replaced atomically, preventing the temporary null session and unwanted redirect. References: - [verifyTOTP and disableTwoFactor implementation](https://github.com/better-auth/better-auth/blob/b96672393c156a5caf0d0f315379a79d195a948c/packages/better-auth/src/plugins/two-factor/index.ts#L141-L366) - [setSessionCookie supports atomic overwrite](https://github.com/better-auth/better-auth/blob/b96672393c156a5caf0d0f315379a79d195a948c/packages/better-auth/src/cookies/index.ts#L18-L237) - [nextCookies plugin sets cookies via Next.js API](https://github.com/better-auth/better-auth/blob/b96672393c156a5caf0d0f315379a79d195a948c/packages/better-auth/src/integrations/next-js.ts#L21-L81) This change should resolve the session flicker and redirect problem in your Next.js 2FA flows. <!-- 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/e0892bed-a490-4d15-bd0e-71eac9a10bcf?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/e0892bed-a490-4d15-bd0e-71eac9a10bcf?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/e0892bed-a490-4d15-bd0e-71eac9a10bcf?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/e0892bed-a490-4d15-bd0e-71eac9a10bcf?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/e0892bed-a490-4d15-bd0e-71eac9a10bcf?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/e0892bed-a490-4d15-bd0e-71eac9a10bcf?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/e0892bed-a490-4d15-bd0e-71eac9a10bcf?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/6077)
Author
Owner

@rovertrack commented on GitHub (Nov 19, 2025):

is it solved ??

<!-- gh-comment-id:3552831739 --> @rovertrack commented on GitHub (Nov 19, 2025): is it solved ??
Author
Owner

@SanderVHGD commented on GitHub (Nov 19, 2025):

@rovertrack I created a PR with a potential fix based on the initial assumptions. I was not able to test yet if this fixes the issue.

<!-- gh-comment-id:3554471710 --> @SanderVHGD commented on GitHub (Nov 19, 2025): @rovertrack I created a PR with a potential fix based on the initial assumptions. I was not able to test yet if this fixes the issue.
Author
Owner

@luratnieks commented on GitHub (Nov 19, 2025):

I'm having similar problems with TOTP; in Postman I'm getting an INVALID_TWO_FACTOR_COOKIE error.

<!-- gh-comment-id:3555072873 --> @luratnieks commented on GitHub (Nov 19, 2025): I'm having similar problems with TOTP; in Postman I'm getting an INVALID_TWO_FACTOR_COOKIE error.
Author
Owner

@dosubot[bot] commented on GitHub (Feb 18, 2026):

Hi, @SanderVHGD. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary

  • You reported that enabling 2FA in a Next.js app with better-auth causes session cookies to be deleted and recreated, leading to unwanted page refreshes and redirects.
  • The root cause was identified as the sequential deletion and setting of cookies, causing a brief null session.
  • I recommended atomically overwriting the session cookie to prevent this issue.
  • You created a PR with a potential fix but have not yet confirmed if it resolves the problem.
  • Another user also reported similar TOTP-related errors, indicating this might affect multiple users.

Next Steps

  • Please let me know if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open.
  • If I don’t hear back within 7 days, I will automatically close this issue.

Thanks for your understanding and contribution!

<!-- gh-comment-id:3921724232 --> @dosubot[bot] commented on GitHub (Feb 18, 2026): Hi, @SanderVHGD. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary** - You reported that enabling 2FA in a Next.js app with better-auth causes session cookies to be deleted and recreated, leading to unwanted page refreshes and redirects. - The root cause was identified as the sequential deletion and setting of cookies, causing a brief null session. - I recommended atomically overwriting the session cookie to prevent this issue. - You created a PR with a potential fix but have not yet confirmed if it resolves the problem. - Another user also reported similar TOTP-related errors, indicating this might affect multiple users. **Next Steps** - Please let me know if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open. - If I don’t hear back within 7 days, I will automatically close this issue. Thanks for your understanding and contribution!
Author
Owner

@SanderVHGD commented on GitHub (Feb 24, 2026):

This issue is still not fixed in verson 1.4.19.

<!-- gh-comment-id:3954429671 --> @SanderVHGD commented on GitHub (Feb 24, 2026): This issue is still not fixed in verson `1.4.19`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19047