diff --git a/dictionaries/privacyidea.definition.json b/dictionaries/privacyidea.definition.json
index 9cf5d288f5ddbef6f09d19f1c0692674fc2561be..27694ba7b359ea4bda84f93a9bd7feec39cab357 100644
--- a/dictionaries/privacyidea.definition.json
+++ b/dictionaries/privacyidea.definition.json
@@ -92,6 +92,9 @@
   "error_message": {
     "en": "Verification was not successful. Please try again."
   },
+  "failcounter_error_message": {
+    "en": "Verification code you have entered is either incorrect or this method of authentication has been deactivated for your account due to numerous failed login attempts. If your login attempts using verification codes keep failing, please use a recovery code or a security key."
+  },
   "error": {
     "en": "Error"
   }
diff --git a/dictionaries/privacyidea.translation.json b/dictionaries/privacyidea.translation.json
index ccfceef395861ca9a1b391dd57eb0f3ca0e91b52..2b1368c841d8dd5033ad13a80bbc9484e9c5656f 100644
--- a/dictionaries/privacyidea.translation.json
+++ b/dictionaries/privacyidea.translation.json
@@ -138,6 +138,9 @@
   "error_message": {
     "cs": "Ověření nebylo úspěšné. Zkuste to znovu nebo použijte jinou metodu."
   },
+  "failcounter_error_message": {
+    "cs": "Zadaný ověřovací kód je nesprávný, nebo byly ověřovací kódy ve Vašem účtu zablokovány (např. kvůli velkému počtu neúspěšných pokusů). Pokud se Vám opakovaně nedaří použít ověřovací kód, prosím použijte bezpečnostní klíč nebo záložní kód."
+  },
   "error": {
     "cs": "Chyba"
   }
diff --git a/lib/Auth/Utils.php b/lib/Auth/Utils.php
index 5e7d456de5e2da87de1888616fc371bb57b7a156..adff02f9bd55ec7d4e331e53c667733cddbb0ed6 100644
--- a/lib/Auth/Utils.php
+++ b/lib/Auth/Utils.php
@@ -103,7 +103,28 @@ class Utils
                     self::handlePrivacyIDEAException($e, $state);
                 }
             }
+        } elseif ($formParams['mode'] === 'totp') {
+            try {
+                // limit otp validation to totp tokens to prevent incrementing of webauthn failcounter
+                $params["type"] = "totp";
+                $params["user"] = $username;
+                $params["pass"] = $formParams['otp'];
+                $headers = [];
+
+                $rawResponse = $pi->sendRequest($params, $headers, 'POST', '/validate/check');
+                $response = PIResponse::fromJSON($rawResponse, $pi);
+
+                $isAuthUnuccessful = $response->value === false;
+                if ($isAuthUnuccessful) {
+                    // prepare custom error message placeholder - failcounter might have been exceeded
+                    Logger::debug("Original TOTP validation response error message: " . $response->errorMessage);
+                    $response->errorMessage = "possible failcounter exceeded";
+                }
+            } catch (\Exception $e) {
+                self::handlePrivacyIDEAException($e, $state);
+            }
         } else {
+            // Backup code validation
             try {
                 $response = $pi->validateCheck($username, $formParams['otp'], $transactionID);
             } catch (\Exception $e) {
@@ -318,7 +339,7 @@ class Utils
         } else {
             // Unexpected response
             Logger::error('privacyIDEA: ' . $response->message);
-            $state['privacyidea:privacyidea']['errorMessage'] = $response->message;
+            $state['privacyidea:privacyidea']['errorMessage'] = $response->errorMessage;
         }
 
         return State::saveState($state, 'privacyidea:privacyidea');
diff --git a/locales/cs/LC_MESSAGES/privacyidea.po b/locales/cs/LC_MESSAGES/privacyidea.po
index 52bd280daba1072b6fee10684d3e4474fdb71129..65a541ef1ff2b96689668979a42c34bcc2afa30e 100644
--- a/locales/cs/LC_MESSAGES/privacyidea.po
+++ b/locales/cs/LC_MESSAGES/privacyidea.po
@@ -119,6 +119,9 @@ msgstr "Zkusit znovu"
 msgid "{privacyidea:privacyidea:error_message}"
 msgstr "Ověření nebylo úspěšné. Zkuste to znovu nebo použijte jinou metodu."
 
+msgid "{privacyidea:privacyidea:failcounter_error_message}"
+msgstr "Zadaný kód ověřovací kód je nesprávný nebo byly ověřovací kódy ve Vašem účtu zablokovány (např. kvůli velkému počtu neúspěšných pokusů). Pokud se Vám opakovaně nedaří použít ověřovací kód, prosím použijte bezpečnostní klíč nebo záložní kód."
+
 msgid "{privacyidea:privacyidea:error}"
 msgstr "Chyba"
 
diff --git a/locales/en/LC_MESSAGES/privacyidea.po b/locales/en/LC_MESSAGES/privacyidea.po
index 8be873cc0a986587c1e3ff593d9cbb7fe064b297..e10919bf92b32a6252a1e60acdf82abc7c7f25be 100644
--- a/locales/en/LC_MESSAGES/privacyidea.po
+++ b/locales/en/LC_MESSAGES/privacyidea.po
@@ -117,6 +117,9 @@ msgstr "Try Again"
 msgid "{privacyidea:privacyidea:error_message}"
 msgstr "Verification was not successful. Please try again."
 
+msgid "{privacyidea:privacyidea:failcounter_error_message}"
+msgstr "Verification code you have entered is either incorrect or this method of authentication has been deactivated for your account due to numerous failed login attempts. If your login attempts using verification codes keep failing, please use a recovery code or a security key."
+
 msgid "{privacyidea:privacyidea:error}"
 msgstr "Error"
 
diff --git a/www/FormBuilder.php b/www/FormBuilder.php
index 4345d639f8963f6c57d8ec8c7be52f334257721d..de5efde8ecf52555aebb27688332e700b0550462 100644
--- a/www/FormBuilder.php
+++ b/www/FormBuilder.php
@@ -45,6 +45,13 @@ if (
     $tpl->data['errorCode'] = ($state['privacyidea:privacyidea']['errorCode'] ?? null) ?: '';
     $state['privacyidea:privacyidea']['errorCode'] = '';
     $tpl->data['errorMessage'] = $tpl->t('{privacyidea:privacyidea:error_message}');
+
+    // replace custom error message placeholder
+    $errorMessage = $state['privacyidea:privacyidea']['errorMessage'];
+    if (stripos($errorMessage, "possible failcounter exceeded") !== false) {
+        $tpl->data['errorMessage'] = $tpl->t('{privacyidea:privacyidea:failcounter_error_message}');
+    }
+
     $state['privacyidea:privacyidea']['errorMessage'] = '';
     $stateId = State::saveState($state, 'privacyidea:privacyidea');
 }
diff --git a/www/js/pi-webauthn.js b/www/js/pi-webauthn.js
index c47db7611dd3b265cb0dd90b3703bfef08b28e15..b92844cdda3180a8ac0c55ba10788f67ae6dca6e 100644
--- a/www/js/pi-webauthn.js
+++ b/www/js/pi-webauthn.js
@@ -53,14 +53,14 @@ var pi_webauthn = navigator.credentials ? window.pi_webauthn || {} : null;
     return nChr > 64 && nChr < 91
       ? nChr - 65
       : nChr > 96 && nChr < 123
-      ? nChr - 71
-      : nChr > 47 && nChr < 58
-      ? nChr + 4
-      : nChr === 43
-      ? 62
-      : nChr === 47
-      ? 63
-      : 0;
+        ? nChr - 71
+        : nChr > 47 && nChr < 58
+          ? nChr + 4
+          : nChr === 43
+            ? 62
+            : nChr === 47
+              ? 63
+              : 0;
   };
 
   /**
@@ -80,14 +80,14 @@ var pi_webauthn = navigator.credentials ? window.pi_webauthn || {} : null;
     return nUint6 < 26
       ? nUint6 + 65
       : nUint6 < 52
-      ? nUint6 + 71
-      : nUint6 < 62
-      ? nUint6 - 4
-      : nUint6 === 62
-      ? 43
-      : nUint6 === 63
-      ? 47
-      : 65;
+        ? nUint6 + 71
+        : nUint6 < 62
+          ? nUint6 - 4
+          : nUint6 === 62
+            ? 43
+            : nUint6 === 63
+              ? 47
+              : 65;
   };
 
   /**
@@ -245,26 +245,26 @@ var pi_webauthn = navigator.credentials ? window.pi_webauthn || {} : null;
               aBytes[++nIdx] -
               128
           : nPart > 247 && nPart < 252 && nIdx + 4 < nLen
-          ? ((nPart - 248) << 24) +
-            ((aBytes[++nIdx] - 128) << 18) +
-            ((aBytes[++nIdx] - 128) << 12) +
-            ((aBytes[++nIdx] - 128) << 6) +
-            aBytes[++nIdx] -
-            128
-          : nPart > 239 && nPart < 248 && nIdx + 3 < nLen
-          ? ((nPart - 240) << 18) +
-            ((aBytes[++nIdx] - 128) << 12) +
-            ((aBytes[++nIdx] - 128) << 6) +
-            aBytes[++nIdx] -
-            128
-          : nPart > 223 && nPart < 240 && nIdx + 2 < nLen
-          ? ((nPart - 224) << 12) +
-            ((aBytes[++nIdx] - 128) << 6) +
-            aBytes[++nIdx] -
-            128
-          : nPart > 191 && nPart < 224 && nIdx + 1 < nLen
-          ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128
-          : nPart,
+            ? ((nPart - 248) << 24) +
+              ((aBytes[++nIdx] - 128) << 18) +
+              ((aBytes[++nIdx] - 128) << 12) +
+              ((aBytes[++nIdx] - 128) << 6) +
+              aBytes[++nIdx] -
+              128
+            : nPart > 239 && nPart < 248 && nIdx + 3 < nLen
+              ? ((nPart - 240) << 18) +
+                ((aBytes[++nIdx] - 128) << 12) +
+                ((aBytes[++nIdx] - 128) << 6) +
+                aBytes[++nIdx] -
+                128
+              : nPart > 223 && nPart < 240 && nIdx + 2 < nLen
+                ? ((nPart - 224) << 12) +
+                  ((aBytes[++nIdx] - 128) << 6) +
+                  aBytes[++nIdx] -
+                  128
+                : nPart > 191 && nPart < 224 && nIdx + 1 < nLen
+                  ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128
+                  : nPart,
       );
     }
 
@@ -297,14 +297,14 @@ var pi_webauthn = navigator.credentials ? window.pi_webauthn || {} : null;
         nChr < 0x80
           ? 1
           : nChr < 0x800
-          ? 2
-          : nChr < 0x10000
-          ? 3
-          : nChr < 0x200000
-          ? 4
-          : nChr < 0x4000000
-          ? 5
-          : 6;
+            ? 2
+            : nChr < 0x10000
+              ? 3
+              : nChr < 0x200000
+                ? 4
+                : nChr < 0x4000000
+                  ? 5
+                  : 6;
     }
 
     aBytes = new Uint8Array(nArrLen);