From 2e495d6af037690d7c2d35112aba1186cdaece5b Mon Sep 17 00:00:00 2001 From: TowyTowy Date: Fri, 10 Jul 2026 12:13:02 +0200 Subject: [PATCH] Allow disabling 2FA with a backup/recovery code #5451 The 2FA disable endpoint passed the submitted code straight to otplib's verify(), which only accepts 6-digit numeric TOTP tokens. A backup/recovery code (8 hex characters) makes verify() throw ("Token must be 6 digits" / "Token must contain only digits"), which is returned to the client as a generic 500 "Internal error". As a result, a user who lost their authenticator and logged in with a recovery code could not disable 2FA at all, since the recovery code is the only credential they have. Reuse the existing verifyForLogin() path, which already accepts both a TOTP code and a backup code (and relaxes the secret-length guardrails for legacy secrets), so disabling 2FA works with either, matching login behaviour. Co-Authored-By: Claude Fable 5 --- backend/internal/2fa.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/backend/internal/2fa.js b/backend/internal/2fa.js index 43307e02c3..af306e7cea 100644 --- a/backend/internal/2fa.js +++ b/backend/internal/2fa.js @@ -160,15 +160,14 @@ const internal2fa = { throw new errs.ValidationError("2FA is not enabled"); } - const result = await verify({ - token: code, - secret: auth.meta.totp_secret, - guardrails: createGuardrails({ - MIN_SECRET_BYTES: 10, - }), - }); - - if (!result.valid) { + // Accept either a TOTP code or one of the backup/recovery codes, using the + // same verification path as login. Passing a backup code straight to otplib's + // verify() throws ("Token must contain only digits"), which previously surfaced + // as an "Internal error" and left users who only had their recovery codes + // unable to disable 2FA (see #5451). + const valid = await internal2fa.verifyForLogin(userId, code); + + if (!valid) { throw new errs.AuthError("Invalid verification code"); }