UNAUTHORIZED after enabling + verifying (initially) or disabling 2FA on the server #1128

Closed
opened 2026-03-13 08:23:58 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @DevDuki on GitHub (Apr 29, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Setup better auth with the twoFactor() plugin
  2. Enable 2FA on a user for the first time in the server via auth.api.enableTwoFactor()
  3. Scan the QR code and verify the code given by the user on the server via auth.api.verifyTOTP()
  4. Verification was successful, new session is created by BA: In SvelteKit I set it manually, by parsing the cookie from the response given by the verifiyTOTP function. Right as I return an object from my action I get the UNAUTHORIZED error. But when I refresh everything works fine again, since I updated the session cookie.
  5. Now disable 2FA on that user in the server via auth.api.disableTwoFactor()
  6. Same as in step 4.

Current vs. Expected behavior

After returning data from the server, instead of getting that data in my +page.svelte component I get a 500 UNAUTHORIZED error.

I expect that my action function runs through normally after a successful verification and setting the new session cookie.

What version of Better Auth are you using?

1.2.7

Provide environment information

- OS: MacOS Sequoia 15.1
- Browser: Arc

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

Backend

Auth config (if applicable)


Additional context

This is how my actions look like. Maybe this helps finding a mistake I'm doing.

export const actions = {
	'enable-2fa': async ({ request }) => {
		const intent = 'manage-2fa';
		const formData = await request.formData();
		const password = formData.get('password');

		if (typeof password !== 'string') {
			return fail(400, {
				message: 'Invalid or missing fields',
				intent
			});
		}

		let response;
		try {
			response = await auth.api.enableTwoFactor({
				headers: request.headers,
				body: {
					password
				},
				asResponse: true,
			});
		} catch (err: unknown) {
			console.error('Error while enabling 2FA:', err);
			return fail(500, {
				error: err,
			});
		}

		if (!response.ok) {
			const wrappedFailedAction = await wrapErrorResponseInFailedAction(response);
			return fail(wrappedFailedAction.status ,{
				intent,
				...wrappedFailedAction.data
			});
		}

		const data = await response.json();

		try {
			const qrCode = await qrCodeToDataUrl(data.totpURI);
			return {
				intent,
				qrCode
			}
		} catch (err: unknown) {
			console.error('Error while generating QR Code:', err);
			return fail(500, {
				error: err,
			});
		}
	},
	'setup-2fa': async ({ request, cookies, locals }) => {
		const intent = 'manage-2fa';
		const formData = await request.formData();
		const totpCode = formData.get('totp');

		if (typeof totpCode !== 'string' || totpCode.length === 0) {
			return fail(400, {
				intent,
				message: 'Invalid or missing fields',
				totpCode
			});
		}


		let response;
		try {
			response = await auth.api.verifyTOTP({
				headers: request.headers,
				body: {
					code: totpCode,
				},
				asResponse: true,
			})

			console.log('response', response);
		} catch (err: unknown) {
			console.error('Error while setting up 2FA:', err);
			return fail(500, {
				error: err,
			});
		}

		if (!response?.ok) {
			const wrappedFailedAction = await wrapErrorResponseInFailedAction(response);
			return fail(wrappedFailedAction.status ,{
				intent,
				...wrappedFailedAction.data
			});
		}

		setCookieWithNameFromResponse(COOKIES.SESSION, cookies, response);

		const result = await auth.api.viewBackupCodes({
			body: {
				userId: locals.user?.id ?? ''
			}
		})

		const data = await response.json();
		console.log('data setting up 2fa', data); // <= Logs normally with success data

		return {
			hasSetup2fa: true,
			backupCodes: result.backupCodes,
			message: '2FA enabled successfully!',
			intent
		}
	},
	'disable-2fa': async ({ request, cookies }) => {
		const intent = 'manage-2fa';
		const formData = await request.formData();
		const password = formData.get('password');

		if (typeof password !== 'string') {
			return fail(400, {
				message: 'Invalid or missing field',
				intent
			});
		}

		let response;
		try {
			response = await auth.api.disableTwoFactor({
				headers: request.headers,
				body: {
					password
				},
				asResponse: true,
			})
		} catch (err: unknown) {
			console.error('Error while disabling 2FA:', err);
			return fail(500, {
				error: err,
			});
		}

		if (!response?.ok) {
			const wrappedFailedAction = await wrapErrorResponseInFailedAction(response);
			return fail(wrappedFailedAction.status ,{
				intent,
				...wrappedFailedAction.data
			});
		}

		setCookieWithNameFromResponse(COOKIES.SESSION, cookies, response);

		const data = await response.json();
		console.log('data in disable 2FA:', data); // <= Logs normally with success data

		return {
			intent,
			message: '2FA disabled successfully!',
		}
	}
};
Originally created by @DevDuki on GitHub (Apr 29, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Setup better auth with the `twoFactor()` plugin 2. Enable 2FA on a user for the first time in the server via `auth.api.enableTwoFactor()` 3. Scan the QR code and verify the code given by the user on the server via `auth.api.verifyTOTP()` 4. Verification was successful, new session is created by BA: In SvelteKit I set it manually, by parsing the cookie from the response given by the `verifiyTOTP` function. Right as I return an object from my action I get the `UNAUTHORIZED` error. But when I refresh everything works fine again, since I updated the session cookie. 5. Now disable 2FA on that user in the server via `auth.api.disableTwoFactor()` 6. Same as in step 4. ### Current vs. Expected behavior After returning data from the server, instead of getting that data in my `+page.svelte` component I get a 500 `UNAUTHORIZED` error. I expect that my action function runs through normally after a successful verification and setting the new session cookie. ### What version of Better Auth are you using? 1.2.7 ### Provide environment information ```bash - OS: MacOS Sequoia 15.1 - Browser: Arc ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript ``` ### Additional context This is how my actions look like. Maybe this helps finding a mistake I'm doing. ```typescrips export const actions = { 'enable-2fa': async ({ request }) => { const intent = 'manage-2fa'; const formData = await request.formData(); const password = formData.get('password'); if (typeof password !== 'string') { return fail(400, { message: 'Invalid or missing fields', intent }); } let response; try { response = await auth.api.enableTwoFactor({ headers: request.headers, body: { password }, asResponse: true, }); } catch (err: unknown) { console.error('Error while enabling 2FA:', err); return fail(500, { error: err, }); } if (!response.ok) { const wrappedFailedAction = await wrapErrorResponseInFailedAction(response); return fail(wrappedFailedAction.status ,{ intent, ...wrappedFailedAction.data }); } const data = await response.json(); try { const qrCode = await qrCodeToDataUrl(data.totpURI); return { intent, qrCode } } catch (err: unknown) { console.error('Error while generating QR Code:', err); return fail(500, { error: err, }); } }, 'setup-2fa': async ({ request, cookies, locals }) => { const intent = 'manage-2fa'; const formData = await request.formData(); const totpCode = formData.get('totp'); if (typeof totpCode !== 'string' || totpCode.length === 0) { return fail(400, { intent, message: 'Invalid or missing fields', totpCode }); } let response; try { response = await auth.api.verifyTOTP({ headers: request.headers, body: { code: totpCode, }, asResponse: true, }) console.log('response', response); } catch (err: unknown) { console.error('Error while setting up 2FA:', err); return fail(500, { error: err, }); } if (!response?.ok) { const wrappedFailedAction = await wrapErrorResponseInFailedAction(response); return fail(wrappedFailedAction.status ,{ intent, ...wrappedFailedAction.data }); } setCookieWithNameFromResponse(COOKIES.SESSION, cookies, response); const result = await auth.api.viewBackupCodes({ body: { userId: locals.user?.id ?? '' } }) const data = await response.json(); console.log('data setting up 2fa', data); // <= Logs normally with success data return { hasSetup2fa: true, backupCodes: result.backupCodes, message: '2FA enabled successfully!', intent } }, 'disable-2fa': async ({ request, cookies }) => { const intent = 'manage-2fa'; const formData = await request.formData(); const password = formData.get('password'); if (typeof password !== 'string') { return fail(400, { message: 'Invalid or missing field', intent }); } let response; try { response = await auth.api.disableTwoFactor({ headers: request.headers, body: { password }, asResponse: true, }) } catch (err: unknown) { console.error('Error while disabling 2FA:', err); return fail(500, { error: err, }); } if (!response?.ok) { const wrappedFailedAction = await wrapErrorResponseInFailedAction(response); return fail(wrappedFailedAction.status ,{ intent, ...wrappedFailedAction.data }); } setCookieWithNameFromResponse(COOKIES.SESSION, cookies, response); const data = await response.json(); console.log('data in disable 2FA:', data); // <= Logs normally with success data return { intent, message: '2FA disabled successfully!', } } }; ```
Author
Owner

@DevDuki commented on GitHub (May 1, 2025):

I noticed that when I redirect, instead of returning data from the actions, I don't get an UNAUTHORIZED error. The redirect works as expected. But why is that? I would love to return a success message or something.

@DevDuki commented on GitHub (May 1, 2025): I noticed that when I redirect, instead of returning data from the actions, I don't get an `UNAUTHORIZED` error. The redirect works as expected. But why is that? I would love to return a success message or something.
Author
Owner

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

Hey! I'm facing related issues in SvelteKit. A few thoughts:

  1. The enableTwoFactor returns both the totpURI and the backupCodes. You can return those to the frontend and store them to use in the ?/verify form action, like so. I'm using Superforms.
		try {
			const { totpURI, backupCodes } = await auth.api.enableTwoFactor({
				body: { password },
				headers: request.headers
			})

			const totpQrUrl = await QRCode.toDataURL(totpURI)

			// Clean up the password JWT cookie now that it has been used
			cookies.delete('temp-password-jwt', { path: '/' })

			// Return QR URL and backup codes directly with the form
			enableForm.data.totpQrUrl = totpQrUrl
			enableForm.data.backupCodes = backupCodes
			return { enableForm }
		} catch (e) {
			console.error('Error enabling 2FA:', e)
			return message(enableForm, {
				type: 'error',
				text: 'Failed to enable 2FA. Please try again.'
			})
		}
  1. For the cookie handling, somebody created a Svelte cookie helper. I've added this as a plugin in my BetterAuth config and now most cookies are being correctly set on the client.

  2. While this solves the setting of cookies and correctly allows TOTP to be verified, when that user logs out and has to sign-in with TOTP, I get INVALID_TWO_FACTOR_COOKIES. I think it's because the await auth.api.verifyTOTP API call isn't emitting the right cookies for the cookie helper to intercept and set on the client.

@budivoogt commented on GitHub (Jun 27, 2025): Hey! I'm facing related issues in SvelteKit. A few thoughts: 1. The enableTwoFactor returns both the `totpURI` and the `backupCodes`. You can return those to the frontend and store them to use in the `?/verify` form action, like so. I'm using Superforms. ``` try { const { totpURI, backupCodes } = await auth.api.enableTwoFactor({ body: { password }, headers: request.headers }) const totpQrUrl = await QRCode.toDataURL(totpURI) // Clean up the password JWT cookie now that it has been used cookies.delete('temp-password-jwt', { path: '/' }) // Return QR URL and backup codes directly with the form enableForm.data.totpQrUrl = totpQrUrl enableForm.data.backupCodes = backupCodes return { enableForm } } catch (e) { console.error('Error enabling 2FA:', e) return message(enableForm, { type: 'error', text: 'Failed to enable 2FA. Please try again.' }) } ``` 2. For the cookie handling, somebody created a [Svelte cookie helper](https://github.com/better-auth/better-auth/pull/3049). I've added this as a plugin in my BetterAuth config and now most cookies are being correctly set on the client. 3. While this solves the setting of cookies and correctly allows TOTP to be verified, when that user logs out and has to sign-in with TOTP, I get `INVALID_TWO_FACTOR_COOKIES`. I think it's because the `await auth.api.verifyTOTP` API call isn't emitting the right cookies for the cookie helper to intercept and set on the client.
Author
Owner

@DevDuki commented on GitHub (Jul 24, 2025):

@budivoogt Thanks for your suggestion! I update BA to 1.3.3 with the new svelte cookie helper and I am still getting the UNAUTHORIZED error when I am trying to return data from my action after calling auth.api.verifyTOTP. Do you have that issue too, or are you verifying it on the client?

The same happens when I disable 2FA, how do you handle disabling 2FA?

One workaround I can think of is redirecting back to the same page and pass the data via query params or something (not a big fan of this approach tho). Because for some reason when I call a redirect(), instead of returning a normal json data, I DONT get the UNAUTHORIZED error, which is so weird! 🙈

This is how my action looks like, when verifying the code given by the user.

'setup-2fa': async ({ request, locals }) => {
		const formData = await request.formData();
		const totpCode = formData.get('totp');
		const deviceName = formData.get('device-name');

		if (typeof totpCode !== 'string' || typeof deviceName !== 'string' || totpCode.length === 0) {
			return fail(400, {
				message: 'Invalid or missing fields',
				totpCode,
				deviceName
			});
		}

		let response;
		try {
			response = await auth.api.verifyTOTP({
				headers: request.headers,
				body: {
					code: totpCode
				},
				asResponse: true
			});
		} catch (err: unknown) {
			console.error('Error while setting up 2FA:', err);
			return fail(500, {
				error: err
			});
		}

		if (!response?.ok) {
			return wrapErrorResponseInFailedAction(response);
		}

                // redirect(302, route('/(protected)/account/twoFactor')); <= Works totally fine

                // Returning this data below causes an `UNAUTHORIZED` error
		return {
			hasSetup2fa: true,
			message: '2FA enabled successfully!'
		};
	},
  1. While this solves the setting of cookies and correctly allows TOTP to be verified, when that user logs out and has to sign-in with TOTP, I get INVALID_TWO_FACTOR_COOKIES. I think it's because the await auth.api.verifyTOTP API call isn't emitting the right cookies for the cookie helper to intercept and set on the client.

I am also experiencing this.

@DevDuki commented on GitHub (Jul 24, 2025): @budivoogt Thanks for your suggestion! I update BA to `1.3.3` with the new svelte cookie helper and I am still getting the `UNAUTHORIZED` error when I am trying to return data from my action after calling `auth.api.verifyTOTP`. Do you have that issue too, or are you verifying it on the client? The same happens when I disable 2FA, how do you handle disabling 2FA? One workaround I can think of is redirecting back to the same page and pass the data via query params or something (not a big fan of this approach tho). Because for some reason when I call a redirect(), instead of returning a normal json data, I DONT get the `UNAUTHORIZED` error, which is so weird! 🙈 This is how my action looks like, when verifying the code given by the user. ```typescript 'setup-2fa': async ({ request, locals }) => { const formData = await request.formData(); const totpCode = formData.get('totp'); const deviceName = formData.get('device-name'); if (typeof totpCode !== 'string' || typeof deviceName !== 'string' || totpCode.length === 0) { return fail(400, { message: 'Invalid or missing fields', totpCode, deviceName }); } let response; try { response = await auth.api.verifyTOTP({ headers: request.headers, body: { code: totpCode }, asResponse: true }); } catch (err: unknown) { console.error('Error while setting up 2FA:', err); return fail(500, { error: err }); } if (!response?.ok) { return wrapErrorResponseInFailedAction(response); } // redirect(302, route('/(protected)/account/twoFactor')); <= Works totally fine // Returning this data below causes an `UNAUTHORIZED` error return { hasSetup2fa: true, message: '2FA enabled successfully!' }; }, ``` > 3. While this solves the setting of cookies and correctly allows TOTP to be verified, when that user logs out and has to sign-in with TOTP, I get INVALID_TWO_FACTOR_COOKIES. I think it's because the await auth.api.verifyTOTP API call isn't emitting the right cookies for the cookie helper to intercept and set on the client. I am also experiencing this.
Author
Owner

@DevDuki commented on GitHub (Jul 25, 2025):

Thanks to this answer here I found out that the sveltekitCookieHelper plugin has to be the last plugin in the plugins list in the auth config. This solved the INVALID_TWO_FACTOR_COOKIES problem. Why isn't this in the docs?

@DevDuki commented on GitHub (Jul 25, 2025): Thanks to this answer [here](https://www.answeroverflow.com/m/1390457460162035853) I found out that the sveltekitCookieHelper plugin has to be the last plugin in the plugins list in the auth config. This solved the `INVALID_TWO_FACTOR_COOKIES` problem. Why isn't this in the docs?
Author
Owner

@DevDuki commented on GitHub (Jul 25, 2025):

I finally understand why I was getting the UNAUTHORIZED errors. After disabling 2fa or verifying a TOTP a new session cookie is created by BA and the old one expires. Since I immediately return data with the old session cookie, it naturally results in an UNAUTHORIZED error.

So since that has cleared out and together with the comment above, this issue has actually been resolved, hence why I'm going to close this

@DevDuki commented on GitHub (Jul 25, 2025): I finally understand why I was getting the `UNAUTHORIZED` errors. After disabling 2fa or verifying a TOTP a new session cookie is created by BA and the old one expires. Since I immediately return data with the old session cookie, it naturally results in an `UNAUTHORIZED` error. So since that has cleared out and together with the comment above, this issue has actually been resolved, hence why I'm going to close this
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1128