diff --git a/js/twofactor_totp-main-challenge.js b/js/twofactor_totp-main-challenge.js
new file mode 100644
index 000000000..b87ed7956
--- /dev/null
+++ b/js/twofactor_totp-main-challenge.js
@@ -0,0 +1,2 @@
+(()=>{"use strict";const e=document.querySelector('.totp-form input[name="challenge"]');e&&function(e){e.addEventListener("input",()=>{const t=e.value.replace(/\D/g,"");t!==e.value&&(e.value=t)})}(e)})();
+//# sourceMappingURL=twofactor_totp-main-challenge.js.map?v=85242c9a4e4d172c7b4f
\ No newline at end of file
diff --git a/js/twofactor_totp-main-challenge.js.license b/js/twofactor_totp-main-challenge.js.license
new file mode 100644
index 000000000..02786e352
--- /dev/null
+++ b/js/twofactor_totp-main-challenge.js.license
@@ -0,0 +1,8 @@
+SPDX-License-Identifier: AGPL-3.0-or-later
+SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
+
+
+This file is generated from multiple sources. Included packages:
+- nextcloud
+ - version: 1.0.0
+ - license: AGPL-3.0-or-later
diff --git a/js/twofactor_totp-main-challenge.js.map b/js/twofactor_totp-main-challenge.js.map
new file mode 100644
index 000000000..02163b75b
--- /dev/null
+++ b/js/twofactor_totp-main-challenge.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"twofactor_totp-main-challenge.js?v=85242c9a4e4d172c7b4f","mappings":"mBAOA,MAAMA,EAAQC,SAASC,cAAc,sCACjCF,GCYG,SAA0BA,GAChCA,EAAMG,iBAAiB,QAAS,KAC/B,MAAMC,EAAyBJ,EAAMK,MAVzBC,QAAQ,MAAO,IAWvBF,IAAcJ,EAAMK,QACvBL,EAAMK,MAAQD,IAGjB,CDlBCG,CAAiBP,E","sources":["webpack:///twofactor_totp/src/main-challenge.js","webpack:///twofactor_totp/src/challenge.js"],"sourcesContent":["/*\n * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { restrictToDigits } from './challenge.js'\n\nconst input = document.querySelector('.totp-form input[name=\"challenge\"]')\nif (input) {\n\trestrictToDigits(input)\n}\n","/*\n * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\n/**\n * Remove every non-digit character from a TOTP code\n *\n * @param {string} value the raw input value\n * @return {string}\n */\nexport function sanitizeCode(value) {\n\treturn value.replace(/\\D/g, '')\n}\n\n/**\n * Keep only digits in the given input while the user types or pastes\n *\n * @param {HTMLInputElement} input the TOTP code input\n */\nexport function restrictToDigits(input) {\n\tinput.addEventListener('input', () => {\n\t\tconst sanitized = sanitizeCode(input.value)\n\t\tif (sanitized !== input.value) {\n\t\t\tinput.value = sanitized\n\t\t}\n\t})\n}\n"],"names":["input","document","querySelector","addEventListener","sanitized","value","replace","restrictToDigits"],"sourceRoot":""}
\ No newline at end of file
diff --git a/js/twofactor_totp-main-challenge.js.map.license b/js/twofactor_totp-main-challenge.js.map.license
new file mode 120000
index 000000000..a6600d8c4
--- /dev/null
+++ b/js/twofactor_totp-main-challenge.js.map.license
@@ -0,0 +1 @@
+twofactor_totp-main-challenge.js.license
\ No newline at end of file
diff --git a/src/challenge.js b/src/challenge.js
new file mode 100644
index 000000000..e30d945eb
--- /dev/null
+++ b/src/challenge.js
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Remove every non-digit character from a TOTP code
+ *
+ * @param {string} value the raw input value
+ * @return {string}
+ */
+export function sanitizeCode(value) {
+ return value.replace(/\D/g, '')
+}
+
+/**
+ * Keep only digits in the given input while the user types or pastes
+ *
+ * @param {HTMLInputElement} input the TOTP code input
+ */
+export function restrictToDigits(input) {
+ input.addEventListener('input', () => {
+ const sanitized = sanitizeCode(input.value)
+ if (sanitized !== input.value) {
+ input.value = sanitized
+ }
+ })
+}
diff --git a/src/main-challenge.js b/src/main-challenge.js
new file mode 100644
index 000000000..9880e78d5
--- /dev/null
+++ b/src/main-challenge.js
@@ -0,0 +1,11 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { restrictToDigits } from './challenge.js'
+
+const input = document.querySelector('.totp-form input[name="challenge"]')
+if (input) {
+ restrictToDigits(input)
+}
diff --git a/src/tests/challenge.spec.js b/src/tests/challenge.spec.js
new file mode 100644
index 000000000..454ed6bd9
--- /dev/null
+++ b/src/tests/challenge.spec.js
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { restrictToDigits, sanitizeCode } from '../challenge.js'
+
+describe('sanitizeCode', () => {
+ it.each([
+ ['123456', '123456'],
+ ['123 456', '123456'],
+ ['12-34-56', '123456'],
+ ['abc', ''],
+ ['', ''],
+ ])('turns %p into %p', (value, expected) => {
+ expect(sanitizeCode(value)).to.equal(expected)
+ })
+})
+
+describe('restrictToDigits', () => {
+ it('strips non-digits on input', () => {
+ const input = document.createElement('input')
+ restrictToDigits(input)
+
+ input.value = '12a3 4'
+ input.dispatchEvent(new Event('input'))
+
+ expect(input.value).to.equal('1234')
+ })
+
+ it('leaves a digit-only value untouched', () => {
+ const input = document.createElement('input')
+ restrictToDigits(input)
+
+ input.value = '123456'
+ input.dispatchEvent(new Event('input'))
+
+ expect(input.value).to.equal('123456')
+ })
+})
diff --git a/templates/challenge.php b/templates/challenge.php
index 0b3d95323..e887e429a 100644
--- a/templates/challenge.php
+++ b/templates/challenge.php
@@ -6,6 +6,7 @@
*/
style('twofactor_totp', 'style');
+script('twofactor_totp', 'twofactor_totp-main-challenge');
?>
@@ -13,7 +14,7 @@
t('Get the authentication code from the two-factor authentication app on your device.')) ?>