[GH-ISSUE #3217] Server 2FA with TOTP failing with INVALID_TWO_FACTOR_COOKIES message #9524

Closed
opened 2026-04-13 05:01:12 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @budivoogt on GitHub (Jun 29, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3217

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

I have setup server-side authentication. A user first chooses to enable 2FA, where we call auth.api.enableTwoFactor. Then, they can scan a QR code and are asked to verify their TOTP code for the first time. To do this, we call auth.api.verifyTOTP. Both of these actions work.

However once 2FA is enabled for the user, if that user logs out and back in again, and are asked to verify their TOTP code, I get an APIError of type UNAUTHORIZED with message INVALID_TWO_FACTOR_COOKIES. This is the call we make that results in the error. I suspect this is because certain cookies are not correctly emitted to the client, but I'm not sure on what API call.

await auth.api.verifyTOTP({
				body: { code },
				headers: request.headers
			})

I am using the following svelteCookies custom plugin to set cookies on the client:

import type { BetterAuthPlugin } from 'better-auth'
import { createAuthMiddleware } from 'better-auth/api'
import { parseSetCookieHeader } from 'better-auth/cookies'

export const svelteCookies = () => {
	return {
		id: 'svelte-cookies',
		hooks: {
			after: [
				{
					matcher() {
						return true
					},
					handler: createAuthMiddleware(async (ctx) => {
						const returned = ctx.context.responseHeaders

						if (returned instanceof Headers) {
							const setCookies = returned?.get('set-cookie')
							if (!setCookies) return

							const { getRequestEvent } = await import('$app/server')
							const event = getRequestEvent()
							const parsed = parseSetCookieHeader(setCookies)

							for (const [name, { value, ...ops }] of parsed) {
								event.cookies.set(name, value, {
									sameSite: ops.samesite,
									path: ops.path || '/',
									expires: ops.expires,
									secure: ops.secure,
									httpOnly: ops.httponly,
									domain: ops.domain,
									maxAge: ops['max-age'],
									encode: (value) => value
								})
							}
						}
					})
				}
			]
		}
	} satisfies BetterAuthPlugin
}

I'm wondering if this is an error on our side, or if it's an issue with the package and/or SvelteKit implementation. Thank you.

Current vs. Expected behavior

When a user correctly provides a TOTP 2FA code and we call await auth.api.verifyTOTP on the server, a session should be created. Instead, we get an UNAUTHORIZED error. I believe it's this one (see invalid).

What version of Better Auth are you using?

1.2.10

Provide environment information

- Better Auth: 1.2.10
- Better Auth CLI: 1.2.10
- Node.js: v20.18.2
- Package Manager: pnpm@10.2.1
- OS: macOS (darwin)
- Framework: SvelteKit 2.20.3
- Database: PostgreSQL with Drizzle ORM 0.44.2
- TypeScript: 5.8.2

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

Backend, Client

Auth config (if applicable)

I'm using the twoFactor plugin without options.

Additional context

No response

Originally created by @budivoogt on GitHub (Jun 29, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3217 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce I have setup server-side authentication. A user first chooses to enable 2FA, where we call `auth.api.enableTwoFactor`. Then, they can scan a QR code and are asked to verify their TOTP code for the first time. To do this, we call `auth.api.verifyTOTP`. Both of these actions work. However once 2FA is enabled for the user, if that user logs out and back in again, and are asked to verify their TOTP code, I get an `APIError` of type `UNAUTHORIZED` with message `INVALID_TWO_FACTOR_COOKIES`. This is the call we make that results in the error. I suspect this is because certain cookies are not correctly emitted to the client, but I'm not sure on what API call. ``` await auth.api.verifyTOTP({ body: { code }, headers: request.headers }) ``` I am using the following `svelteCookies` custom plugin to set cookies on the client: ``` import type { BetterAuthPlugin } from 'better-auth' import { createAuthMiddleware } from 'better-auth/api' import { parseSetCookieHeader } from 'better-auth/cookies' export const svelteCookies = () => { return { id: 'svelte-cookies', hooks: { after: [ { matcher() { return true }, handler: createAuthMiddleware(async (ctx) => { const returned = ctx.context.responseHeaders if (returned instanceof Headers) { const setCookies = returned?.get('set-cookie') if (!setCookies) return const { getRequestEvent } = await import('$app/server') const event = getRequestEvent() const parsed = parseSetCookieHeader(setCookies) for (const [name, { value, ...ops }] of parsed) { event.cookies.set(name, value, { sameSite: ops.samesite, path: ops.path || '/', expires: ops.expires, secure: ops.secure, httpOnly: ops.httponly, domain: ops.domain, maxAge: ops['max-age'], encode: (value) => value }) } } }) } ] } } satisfies BetterAuthPlugin } ``` I'm wondering if this is an error on our side, or if it's an issue with the package and/or SvelteKit implementation. Thank you. ### Current vs. Expected behavior When a user correctly provides a TOTP 2FA code and we call `await auth.api.verifyTOTP` on the server, a session should be created. Instead, we get an `UNAUTHORIZED` error. I believe it's [this one](https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts) (see `invalid`). ### What version of Better Auth are you using? 1.2.10 ### Provide environment information ```bash - Better Auth: 1.2.10 - Better Auth CLI: 1.2.10 - Node.js: v20.18.2 - Package Manager: pnpm@10.2.1 - OS: macOS (darwin) - Framework: SvelteKit 2.20.3 - Database: PostgreSQL with Drizzle ORM 0.44.2 - TypeScript: 5.8.2 ``` ### Which area(s) are affected? (Select all that apply) Backend, Client ### Auth config (if applicable) ```typescript I'm using the twoFactor plugin without options. ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-13 05:01:12 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Jun 29, 2025):

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

The INVALID_TWO_FACTOR_COOKIES error during TOTP verification means the required two-factor cookie is missing, invalid, or not being sent as expected. This cookie is set after the initial login and must be present (and valid) when you call verifyTOTP; otherwise, Better Auth will reject the request as unauthorized see source.

Your svelteCookies plugin generally parses and sets all cookie attributes, but subtle mismatches—especially in attribute casing (like httponly vs httpOnly, samesite vs sameSite) or missing attributes—can cause the cookie to not be recognized by SvelteKit or not sent back to the server see source. This is critical for 2FA, since the flow depends on that cookie being present and correct.

Here’s what to check:

  • After the initial login (before TOTP), confirm the two-factor cookie is actually set in the browser and has the correct attributes (path: '/', httpOnly: true, secure: true if using HTTPS, sameSite: 'lax' by default).

  • When you submit the TOTP code, ensure the cookie is being sent in the request headers to the server.

  • Double-check your attribute mapping in the svelteCookies plugin. SvelteKit expects camelCase (httpOnly, sameSite), but your code uses the parsed keys directly (httponly, samesite). Try mapping these explicitly:

    event.cookies.set(name, value, {
      sameSite: ops.samesite,
      path: ops.path || '/',
      expires: ops.expires,
      secure: ops.secure,
      httpOnly: ops.httponly, // <-- should be httpOnly: ops.httponly
      domain: ops.domain,
      maxAge: ops['max-age'],
      encode: (value) => value
    })
    

    Change to:

    event.cookies.set(name, value, {
      sameSite: ops.samesite,
      path: ops.path || '/',
      expires: ops.expires,
      secure: ops.secure,
      httpOnly: ops.httponly ?? ops.httpOnly, // handle both
      domain: ops.domain,
      maxAge: ops['max-age'],
      encode: (value) => value
    })
    

    Or, better, normalize all attribute keys to what SvelteKit expects.

  • If you’re behind a proxy or using SSR, make sure cookies aren’t being stripped or altered by your network setup.

If the cookie is missing, expired, or not signed as expected, Better Auth will throw this error. Logging the cookies on both client and server before the verifyTOTP call can help pinpoint where the breakdown occurs. If you confirm the cookie is present and correct but still get the error, there may be a subtle bug in how the cookie is signed or a mismatch in the secret.

Let me know if you want a more detailed code patch for the attribute mapping or if you have logs showing the cookie headers before the failing request.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3016768573 --> @dosubot[bot] commented on GitHub (Jun 29, 2025): <!-- Greeting --> Hi @budivoogt! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> The INVALID_TWO_FACTOR_COOKIES error during TOTP verification means the required two-factor cookie is missing, invalid, or not being sent as expected. This cookie is set after the initial login and must be present (and valid) when you call verifyTOTP; otherwise, Better Auth will reject the request as unauthorized [see source](https://github.com/better-auth/better-auth/blob/7c4719ed77941bc1a685dc16550be10ad980f3df/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts). Your svelteCookies plugin generally parses and sets all cookie attributes, but subtle mismatches—especially in attribute casing (like `httponly` vs `httpOnly`, `samesite` vs `sameSite`) or missing attributes—can cause the cookie to not be recognized by SvelteKit or not sent back to the server [see source](https://github.com/better-auth/better-auth/blob/7c4719ed77941bc1a685dc16550be10ad980f3df/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts). This is critical for 2FA, since the flow depends on that cookie being present and correct. Here’s what to check: - After the initial login (before TOTP), confirm the two-factor cookie is actually set in the browser and has the correct attributes (`path: '/'`, `httpOnly: true`, `secure: true` if using HTTPS, `sameSite: 'lax'` by default). - When you submit the TOTP code, ensure the cookie is being sent in the request headers to the server. - Double-check your attribute mapping in the svelteCookies plugin. SvelteKit expects camelCase (`httpOnly`, `sameSite`), but your code uses the parsed keys directly (`httponly`, `samesite`). Try mapping these explicitly: ```js event.cookies.set(name, value, { sameSite: ops.samesite, path: ops.path || '/', expires: ops.expires, secure: ops.secure, httpOnly: ops.httponly, // <-- should be httpOnly: ops.httponly domain: ops.domain, maxAge: ops['max-age'], encode: (value) => value }) ``` Change to: ```js event.cookies.set(name, value, { sameSite: ops.samesite, path: ops.path || '/', expires: ops.expires, secure: ops.secure, httpOnly: ops.httponly ?? ops.httpOnly, // handle both domain: ops.domain, maxAge: ops['max-age'], encode: (value) => value }) ``` Or, better, normalize all attribute keys to what SvelteKit expects. - If you’re behind a proxy or using SSR, make sure cookies aren’t being stripped or altered by your network setup. If the cookie is missing, expired, or not signed as expected, Better Auth will throw this error. Logging the cookies on both client and server before the verifyTOTP call can help pinpoint where the breakdown occurs. If you confirm the cookie is present and correct but still get the error, there may be a subtle bug in how the cookie is signed or a mismatch in the secret. Let me know if you want a more detailed code patch for the attribute mapping or if you have logs showing the cookie headers before the failing request. <!-- 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/eae45b3e-5e6c-4778-a4e4-a4c85e2b0a5a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/eae45b3e-5e6c-4778-a4e4-a4c85e2b0a5a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/eae45b3e-5e6c-4778-a4e4-a4c85e2b0a5a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/eae45b3e-5e6c-4778-a4e4-a4c85e2b0a5a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/eae45b3e-5e6c-4778-a4e4-a4c85e2b0a5a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/eae45b3e-5e6c-4778-a4e4-a4c85e2b0a5a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/eae45b3e-5e6c-4778-a4e4-a4c85e2b0a5a?feedback_type=other)</sup>&nbsp;&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/3217)
Author
Owner

@budivoogt commented on GitHub (Jun 29, 2025):

@dosubot @dosu I am unable to consistently get the two factor cookie when logging in. I am passing the request headers to the server-side API call however. Which API calls produce the two-factor cookie? Is that exclusively the auth.api.signInEmail call?

<!-- gh-comment-id:3017037679 --> @budivoogt commented on GitHub (Jun 29, 2025): @dosubot @dosu I am unable to consistently get the two factor cookie when logging in. I am passing the request headers to the server-side API call however. Which API calls produce the two-factor cookie? Is that exclusively the `auth.api.signInEmail` call?
Author
Owner

@dosubot[bot] commented on GitHub (Jun 29, 2025):

The two-factor cookie is not set exclusively by auth.api.signInEmail. Any authentication method (like signInEmail, signInPassword, OAuth, etc.) that reaches the "needs 2FA" state will trigger the two-factor plugin to set this cookie, as long as 2FA is required for the user account. The actual logic for setting the cookie is handled by the two-factor plugin, not by individual sign-in methods—so whichever flow leads to a 2FA challenge will issue the cookie to the client, and it's required for the subsequent TOTP verification step. If you're not consistently seeing the cookie, double-check that your cookie handling logic is capturing all Set-Cookie headers from any sign-in flow that triggers 2FA, not just email sign-in. source

To reply, just mention @dosu.


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

<!-- gh-comment-id:3017041304 --> @dosubot[bot] commented on GitHub (Jun 29, 2025): <!-- Answer --> The two-factor cookie is not set exclusively by auth.api.signInEmail. Any authentication method (like signInEmail, signInPassword, OAuth, etc.) that reaches the "needs 2FA" state will trigger the two-factor plugin to set this cookie, as long as 2FA is required for the user account. The actual logic for setting the cookie is handled by the two-factor plugin, not by individual sign-in methods—so whichever flow leads to a 2FA challenge will issue the cookie to the client, and it's required for the subsequent TOTP verification step. If you're not consistently seeing the cookie, double-check that your cookie handling logic is capturing all Set-Cookie headers from any sign-in flow that triggers 2FA, not just email sign-in. [source](https://github.com/better-auth/better-auth/blob/7c4719ed77941bc1a685dc16550be10ad980f3df/packages/better-auth/src/plugins/two-factor/verify-two-factor.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/5fb7e9e5-9de2-4384-b5eb-a4a900aad23a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/5fb7e9e5-9de2-4384-b5eb-a4a900aad23a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/5fb7e9e5-9de2-4384-b5eb-a4a900aad23a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/5fb7e9e5-9de2-4384-b5eb-a4a900aad23a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/5fb7e9e5-9de2-4384-b5eb-a4a900aad23a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/5fb7e9e5-9de2-4384-b5eb-a4a900aad23a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/5fb7e9e5-9de2-4384-b5eb-a4a900aad23a?feedback_type=other)</sup>&nbsp;&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/3217)
Author
Owner

@budivoogt commented on GitHub (Jun 29, 2025):

That's helpful. I have applied your suggestions to my cookieHelper and ensured to pass headers to the auth.api.signInEmail. This seems to have resolved the issue.

<!-- gh-comment-id:3017069184 --> @budivoogt commented on GitHub (Jun 29, 2025): That's helpful. I have applied your suggestions to my cookieHelper and ensured to pass headers to the `auth.api.signInEmail`. This seems to have resolved the issue.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9524