fullstackhero/dotnet-starter-kit · error · UnauthorizedException

two_factor_invalid: The authenticator code is invalid or…

Error message

two_factor_invalid: The authenticator code is invalid or expired.

What it means

VerifyTwoFactorOrThrowAsync validates the supplied code via UserManager.VerifyTwoFactorTokenAsync with the AuthenticatorTokenProvider; when the code does not match (wrong, stale, already used, or clock drift) it logs a warning for the user and throws UnauthorizedException with the two_factor_invalid marker.

Solutions

  1. Generate and submit a fresh code immediately; if it fails, wait for the next TOTP window and retry once.
  2. Check the device/system clock synchronization (NTP) — skewed clocks are the top cause.
  3. Confirm the authenticator entry matches the account's current secret; re-enroll by re-scanning the QR if the secret was reset.
  4. Trim spaces from the pasted code and ensure you send a TOTP code, not a recovery code, to this path.

Example fix

// before
await login({ email, password, twoFactorCode: cachedCode }); // stale code -> 401
// after
const code = await promptTotp(); // fresh from authenticator, trimmed
await login({ email, password, twoFactorCode: code.trim() });
Defensive patterns

Strategy: retry

Validate before calling

const code = getFreshTotpCode().trim();
if (!/^\d{6}$/.test(code)) { showError('Enter the 6-digit code'); return; }

Type guard

function isValidTotpFormat(code) {
  return typeof code === 'string' && /^\d{6}$/.test(code.trim());
}

Try / catch

try { await login({ email, password, twoFactorCode: code }); }
catch (e) {
  if (String(e.message).includes('two_factor_invalid')) {
    await waitForNextTotpWindow();
    return retryWithFreshCode(); // one retry max
  }
  throw e;
}

Prevention

When it happens

Trigger: Typing an expired TOTP (code window passed), a code generated against a wrong secret (e.g. re-enrolled authenticator), garbage/placeholder values, or a recovery code sent where a TOTP token is expected.

Common situations: User's device clock skewed by minutes; user re-scanned the QR so old app entries are invalid; copy-paste including whitespace; latency between generating and submitting the last seconds of a 30s window.

Understand the failure class

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/67024915151fa8e5. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Identity/Modules.Identity/Services/IdentityService.cs:86

    private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCode)
    {
        if (string.IsNullOrWhiteSpace(twoFactorCode))
        {
            throw new CustomException(
                "two_factor_required: An authenticator code is required to complete sign-in.",
                errors: null,
                HttpStatusCode.Unauthorized);
        }

        var valid = await _userManager.VerifyTwoFactorTokenAsync(
            user,
            _userManager.Options.Tokens.AuthenticatorTokenProvider,
            twoFactorCode);

        if (!valid)
        {
            _logger.LogWarning("Invalid two-factor code for user {UserId}", user.Id);
            throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired.");
        }
    }

    public async Task<(string Subject, IEnumerable<Claim> Claims)?>
        ValidateRefreshTokenAsync(string refreshToken, CancellationToken ct = default)
    {
        var tenant = GetValidatedTenant();
        var user = await FindUserByRefreshTokenAsync(refreshToken, tenant.Id, ct);

        ValidateRefreshTokenExpiry(user);
        ValidateUserStatus(user);
        ValidateTenantStatus(tenant);

        var claims = await BuildUserClaimsAsync(user, tenant.Id, ct);
        return (user.Id, claims);
    }

    public async Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default)

View on GitHub (pinned to 3f2959e683)