diff --git a/languages/en.ini b/languages/en.ini
index bb007928d00f5c44fec9ccf1bb5327311662cd10..ddc7c1e431ec62faba77440789d5418c2c06f2a4 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -675,6 +675,7 @@ Page not found. = "Page not found."
 Password = "Password"
 Password Again = "Password Again"
 Password cannot be blank = "Password cannot be blank"
+password_error_invalid = "New password is invalid (e.g. contains invalid characters)"
 password_error_not_unique = "Password was not changed"
 password_maximum_length = "Maximum password length is %%maxlength%% characters"
 password_minimum_length = "Minimum password length is %%minlength%% characters"
diff --git a/languages/fi.ini b/languages/fi.ini
index a32ecf9ee5d1f5ac173e70a14b7c84ea1010700c..52a2b54e06813cd2bf2769af80b0e30f86f4151d 100644
--- a/languages/fi.ini
+++ b/languages/fi.ini
@@ -665,6 +665,7 @@ Page not found. = "Sivua ei löytynyt."
 Password = "Salasana / PIN*"
 Password Again = "Salasana uudelleen"
 Password cannot be blank = "Salasana ei voi olla tyhjä"
+password_error_invalid = "Uusi salasana on virheellinen (esim. sisältää kiellettyjä merkkejä)"
 password_error_not_unique = "Salasanaa ei muutettu"
 password_maximum_length = "Salasanan enimmäispituus on %%maxlength%% merkkiä"
 password_minimum_length = "Salasanan vähimmäispituus on %%minlength%% merkkiä"
diff --git a/languages/sv.ini b/languages/sv.ini
index c36d1c91d7a31e7085f02fc446f973ec8729983b..0a4786eb5310ee4e7c2a5b49a8e9e17c1cda9674 100644
--- a/languages/sv.ini
+++ b/languages/sv.ini
@@ -664,7 +664,8 @@ Other Sources = "Andra källor"
 Page not found. = "Sidan hittades inte."
 Password = "Lösenord / PIN *"
 Password Again = "Lösenord igen"
-Password cannot be blank = "Lösenordet kan inte lämnas tom"
+Password cannot be blank = "Lösenordet kan inte lämnas tomt"
+password_error_invalid = "Nytt lösenord är ogiltigt (t.ex. innehåller ogiltiga tecken)"
 password_error_not_unique = "Lösenordet inte ändrat"
 password_maximum_length = "Högsta längd på lösenord är %%maxlength%% tecken"
 password_minimum_length = "Minsta längd på lösenord är %%minlength%% tecken"
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Voyager.php b/module/VuFind/src/VuFind/ILS/Driver/Voyager.php
index a0f671e04f1f83389d27f88b7bf9866e4bfef9f4..2469be811081c1fd0253ce133d39303c02885d6a 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Voyager.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Voyager.php
@@ -1156,6 +1156,19 @@ class Voyager extends AbstractBase
             ? [] : $this->getPurchaseHistoryData($id);
     }
 
+    /**
+     * Sanitize patron PIN code (remove characters Voyager doesn't handle properly)
+     *
+     * @param string $pin PIN code to sanitize
+     *
+     * @return string Sanitized PIN code
+     */
+    protected function sanitizePIN($pin)
+    {
+        $pin = preg_replace('/[^0-9a-zA-Z#&<>+^`~]+/', '', $pin);
+        return $pin;
+    }
+
     /**
      * Patron Login
      *
@@ -1216,7 +1229,8 @@ class Voyager extends AbstractBase
                     ? mb_strtolower(utf8_encode($row['FALLBACK_LOGIN']), 'UTF-8')
                     : null;
 
-                if ((!is_null($primary) && $primary == $compareLogin)
+                if ((!is_null($primary) && ($primary == $compareLogin
+                    || $primary == $this->sanitizePIN($compareLogin)))
                     || ($fallback_login_field && is_null($primary)
                     && $fallback == $compareLogin)
                 ) {
diff --git a/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php b/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php
index aa58c03b72a8073a5ea228f45e1520fa20223050..6ab787492efab4e30fe6b1d32954dc0bc0db7f91 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php
@@ -3150,15 +3150,24 @@ EOT;
         $lastname = htmlspecialchars($patron['lastname'], ENT_COMPAT, 'UTF-8');
         $ubId = htmlspecialchars($this->ws_patronHomeUbId, ENT_COMPAT, 'UTF-8');
         $oldPIN = trim(
-            htmlspecialchars($details['oldPassword'], ENT_COMPAT, 'UTF-8')
+            htmlspecialchars(
+                $this->sanitizePIN($details['oldPassword']), ENT_COMPAT, 'UTF-8'
+            )
         );
         if ($oldPIN === '') {
             // Voyager requires the PIN code to be set even if it was empty
             $oldPIN = '     ';
         }
         $newPIN = trim(
-            htmlspecialchars($details['newPassword'], ENT_COMPAT, 'UTF-8')
+            htmlspecialchars(
+                $this->sanitizePIN($details['newPassword']), ENT_COMPAT, 'UTF-8'
+            )
         );
+        if ($newPIN === '') {
+            return [
+                'success' => false, 'status' => 'password_error_invalid'
+            ];
+        }
         $barcode = htmlspecialchars($patron['cat_username'], ENT_COMPAT, 'UTF-8');
 
         $xml =  <<<EOT
@@ -3202,12 +3211,11 @@ EOT;
                 ];
             }
             if ($code == $exceptionNamespace . 'ValidateLengthException') {
-                // This issue should not be encountered if the settings are correct.
-                // Log an error and let through for an exception
-                $this->error(
-                    'ValidateLengthException encountered when trying to'
-                    . ' change patron PIN. Verify PIN length settings.'
-                );
+                // This error may happen even with correct settings if the new PIN
+                // contains invalid characters.
+                return [
+                    'success' => false, 'status' => 'password_error_invalid'
+                ];
             }
             throw new ILSException((string)$error);
         }