diff --git a/app/controllers/accessibility/forms.php b/app/controllers/accessibility/forms.php
index 476f2fecc890be763a39d8c53bdc893498a822ed..e240a6cecb125565e52485cc72ad24b264dbf198 100644
--- a/app/controllers/accessibility/forms.php
+++ b/app/controllers/accessibility/forms.php
@@ -146,6 +146,9 @@ class Accessibility_FormsController extends StudipController
         }
 
         $this->form->addPart($personal_data_part);
+
+        $this->form->addPart(new \Studip\Forms\Captcha());
+
         $this->form->setSaveButtonText(_('Barriere melden'));
         $this->form->setSaveButtonName('report');
         $this->form->setURL($this->report_barrierURL());
diff --git a/app/controllers/captcha.php b/app/controllers/captcha.php
new file mode 100644
index 0000000000000000000000000000000000000000..37bac4745ff91d7267c8246322dbf0271f2c9dc9
--- /dev/null
+++ b/app/controllers/captcha.php
@@ -0,0 +1,12 @@
+<?php
+final class CaptchaController extends StudipController
+{
+    public function challenge_action(): void
+    {
+        $this->response->add_header(
+            'Expires',
+            gmdate('D, d M Y H:i:s', time() + CaptchaChallenge::CHALLENGE_EXPIRATION) . ' GMT'
+        );
+        $this->render_json(CaptchaChallenge::createNewChallenge());
+    }
+}
diff --git a/db/migrations/6.0.2_captcha_by_altcha.php b/db/migrations/6.0.2_captcha_by_altcha.php
new file mode 100644
index 0000000000000000000000000000000000000000..3749c7140dde074b858f8490795a5df53a917baa
--- /dev/null
+++ b/db/migrations/6.0.2_captcha_by_altcha.php
@@ -0,0 +1,39 @@
+<?php
+return new class extends Migration
+{
+    public function description(): string
+    {
+        return 'Creates a config entry for the key used for captchas and '
+             . 'db storage for solved challenges.';
+    }
+
+    protected function up(): void
+    {
+        $query = "INSERT INTO `config` (`field`, `value`, `type`, `range`, `mkdate`, `chdate`, `description`)
+                  VALUES ('CAPTCHA_KEY', '', 'string', 'global', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), ?)";
+        DBManager::get()->execute($query, [
+            'Speichert den für Captchas verwendeten Schlüssel (Wert leeren, um einen neuen zu generieren)',
+        ]);
+
+        $query = "CREATE TABLE `captcha_challenges` (
+                      `challenge_id` int(11) NOT NULL AUTO_INCREMENT,
+                      `salt` CHAR(32) COLLATE `latin1_bin` NOT NULL,
+                      `number` INT(11) UNSIGNED NOT NULL,
+                      `mkdate` INT(11) UNSIGNED NOT NULL,
+                      PRIMARY KEY (`challenge_id`)                        
+                  )";
+        DBManager::get()->exec($query);
+    }
+
+    protected function down(): void
+    {
+        $query = "DROP TABLE `captcha_challenges`";
+        DBManager::get()->exec($query);
+
+        $query = "DELETE `config`, `config_values`
+                  FROM `config`
+                  LEFT JOIN `config_values` USING (`field`)
+                  WHERE `field` = 'CAPTCHA_KEY'";
+        DBManager::get()->exec($query);
+    }
+};
diff --git a/lib/classes/forms/Captcha.php b/lib/classes/forms/Captcha.php
new file mode 100644
index 0000000000000000000000000000000000000000..c01b702815e4ce46b12b566ecaa4377c92a29ba7
--- /dev/null
+++ b/lib/classes/forms/Captcha.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Studip\Forms;
+
+use CaptchaChallenge;
+
+/**
+ * The Text class represents a part of a form that displays a captcha.
+ */
+class Captcha extends Fieldset
+{
+    private CaptchaInput $captcha_input;
+
+    public function __construct()
+    {
+        parent::__construct(_('Bitte bestätigen Sie, dass Sie kein Roboter sind'));
+
+        $captchaInput = new CaptchaInput('altcha', $this->legend, null);
+        $captchaInput->setStoringFunction(function (string $payload) {
+            $json = CaptchaChallenge::decodePayload($payload);
+
+            CaptchaChallenge::create([
+                'salt'   => $json['salt'],
+                'number' => $json['number'],
+            ]);
+        });
+        $this->addInput($captchaInput);
+    }
+}
diff --git a/lib/classes/forms/CaptchaInput.php b/lib/classes/forms/CaptchaInput.php
new file mode 100644
index 0000000000000000000000000000000000000000..6476f87b7fe0b3b0f3a2710756eeec42d6ded0a5
--- /dev/null
+++ b/lib/classes/forms/CaptchaInput.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Studip\Forms;
+
+use CaptchaChallenge;
+use URLHelper;
+
+/**
+ * The Text class represents a part of a form that displays a captcha.
+ */
+final class CaptchaInput extends Input
+{
+    public function hasValidation(): bool
+    {
+        return true;
+    }
+
+    public function getValidationCallback(): callable
+    {
+        return fn($value) => \CaptchaChallenge::validatePayload($value);
+    }
+
+    public function render(): string
+    {
+        return sprintf(
+            '<captcha-input challenge-url="%s" v-model="%s" auto="onload"></captcha-input>',
+            URLHelper::getLink('dispatch.php/captcha/challenge', [], true),
+            htmlReady($this->name)
+        );
+    }
+
+    public function renderWithCondition(): string
+    {
+        return $this->render();
+    }
+
+
+}
diff --git a/lib/classes/forms/Form.php b/lib/classes/forms/Form.php
index fa0422e1a92070e3e7ed68fccfa69db2ebb94304..8977a3732a09db9fd54906c6954617acafc06541 100644
--- a/lib/classes/forms/Form.php
+++ b/lib/classes/forms/Form.php
@@ -309,7 +309,7 @@ class Form extends Part
             //verify the user input:
             $output = [];
             foreach ($this->getAllInputs() as $input) {
-                if ($input->validate) {
+                if ($input->hasValidation()) {
                     $callback = $input->getValidationCallback();
                     $value = $this->getStorableValueFromRequest($input);
                     $valid = $callback($value, $input);
@@ -317,7 +317,7 @@ class Form extends Part
                         $output[$input->getName()] = [
                             'name' => $input->getName(),
                             'label' => $input->getTitle(),
-                            'error' => $callback($value, $input)
+                            'error' => $valid,
                         ];
                     }
                 }
@@ -396,7 +396,7 @@ class Form extends Part
         $stored = 0;
 
         foreach ($this->getAllInputs() as $input) {
-            if ($input->validate) {
+            if ($input->hasValidation()) {
                 $callback = $input->getValidationCallback();
                 $value = $this->getStorableValueFromRequest($input);
                 $valid = $callback($value, $input);
@@ -450,7 +450,7 @@ class Form extends Part
 
     /**
      * Returns all the Part objects like Fieldsets as an array.
-     * @return array
+     * @return Part[]
      */
     public function getParts() : array
     {
diff --git a/lib/classes/forms/Part.php b/lib/classes/forms/Part.php
index fdca8f5395af8e88dd99b8b5f05bba4735709360..779cab76bbbbfb318b523f17886b9e06b64c91c9 100644
--- a/lib/classes/forms/Part.php
+++ b/lib/classes/forms/Part.php
@@ -139,7 +139,7 @@ abstract class Part
 
     /**
      * Recursively returns all Input elements attached to this Part object or any child Parts.
-     * @return array
+     * @return Input[]
      */
     public function getAllInputs()
     {
diff --git a/lib/cronjobs/garbage_collector.class.php b/lib/cronjobs/garbage_collector.class.php
index 13d3029a0cadc4d7d40ed19ae2f4a2f911371f91..e426cf33b9b6cde129519d390add4f4593cf3e14 100644
--- a/lib/cronjobs/garbage_collector.class.php
+++ b/lib/cronjobs/garbage_collector.class.php
@@ -195,5 +195,8 @@ class GarbageCollectorJob extends CronJob
             'mkdate < UNIX_TIMESTAMP() - ?',
             [TFASecret::getGreatestValidityDuration()]
         );
+
+        // Remove expired solved captcha challenges
+        CaptchaChallenge::gc();
     }
 }
diff --git a/lib/models/CaptchaChallenge.php b/lib/models/CaptchaChallenge.php
new file mode 100644
index 0000000000000000000000000000000000000000..446985d9abeafdd0a7984fec6c8d465971bf9081
--- /dev/null
+++ b/lib/models/CaptchaChallenge.php
@@ -0,0 +1,88 @@
+<?php
+final class CaptchaChallenge extends SimpleORMap
+{
+    public const ALGORITHM = 'SHA-256';
+    public const CHALLENGE_EXPIRATION = 5 * 60;
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'captcha_challenges';
+
+        parent::configure($config);
+    }
+
+    protected static function getKey(): string
+    {
+        $key = Config::get()->CAPTCHA_KEY;
+        if ($key === '') {
+            $key = bin2hex(random_bytes(32));
+            Config::get()->store('CAPTCHA_KEY', $key);
+        }
+        return $key;
+    }
+
+    public static function createChallenge(string $salt, int $number): array
+    {
+        $algorithm = 'sha256';
+        $challenge = hash($algorithm, $salt . $number);
+        $signature = hash_hmac($algorithm, $challenge, self::getKey());
+
+        return [
+            'algorithm' => self::ALGORITHM,
+            'challenge' => $challenge,
+            'salt'      => $salt,
+            'signature' => $signature,
+        ];
+    }
+
+    public static function createNewChallenge(): array
+    {
+        do {
+            $salt = time() . '-' . bin2hex(random_bytes(12));
+            $number = random_int(1e3, 1e5);
+        } while (self::countBySql('salt = ? AND number = ?', [$salt, $number]) > 0);
+
+        return self::createChallenge($salt, $number);
+    }
+
+    public static function decodePayload(string $payload): array|null
+    {
+        return json_decode(base64_decode($payload), true);
+    }
+
+    public static function validatePayload(string $payload): string|bool
+    {
+        $json = self::decodePayload($payload);
+
+        if ($json === null) {
+            return _('Sie haben nicht bestätigt, dass Sie kein Roboter sind');
+        }
+
+        $time = explode('-', $json['salt'])[0];
+        if ($time < time() - self::CHALLENGE_EXPIRATION) {
+            return _('Die Challenge ist abgelaufen');
+        }
+
+        // Replay?
+        if (\CaptchaChallenge::countBySql('salt = ? AND number = ?', [$json['salt'], $json['number']]) > 0) {
+            return _('Nicht schummeln!');
+        }
+
+        $check = self::createChallenge($json['salt'], $json['number']);
+
+        if (
+            $json['algorithm'] !== $check['algorithm']
+            || $json['challenge'] !== $check['challenge']
+            || $json['signature'] !== $check['signature']
+        ) {
+            return _('Sie sind scheinbar ein Roboter...');
+        }
+
+        return true;
+    }
+
+    public static function gc(): void
+    {
+        self::deleteBySQL("mkdate < UNIX_TIMESTAMP() - ?", [self::CHALLENGE_EXPIRATION]);
+    }
+}
diff --git a/package-lock.json b/package-lock.json
index f80853092e84325238ad4cd956c64e6d654535ae..cef443dade3e259e5dc2a5101f8bbaa770e2ceec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -61,6 +61,7 @@
                 "@types/jqueryui": "^1.12.16",
                 "@types/lodash": "^4.14.191",
                 "@vue/eslint-config-typescript": "^12.0.0",
+                "altcha": "^0.3.2",
                 "autoprefixer": "^10.2.5",
                 "axios": "^0.21.0",
                 "babel-loader": "^8.2.1",
@@ -4902,6 +4903,12 @@
             "integrity": "sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ==",
             "dev": true
         },
+        "node_modules/altcha": {
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/altcha/-/altcha-0.3.2.tgz",
+            "integrity": "sha512-5UQP/fwgdlxfhgr4GADoPyMzHWTmDuWq3OloQlZsmUl3C/8+0huWdXW5S8FraA6GWK8iEwbG/2IR4TehLTY9cQ==",
+            "dev": true
+        },
         "node_modules/ansi-colors": {
             "version": "4.1.3",
             "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
diff --git a/package.json b/package.json
index fef26f2cd91df069502e21bf9e1f93ee40ff9672..3d382e5aa1a4b0e77e04f4cf8d6406f2bc5d9f99 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
         "@types/jqueryui": "^1.12.16",
         "@types/lodash": "^4.14.191",
         "@vue/eslint-config-typescript": "^12.0.0",
+        "altcha": "^0.3.2",
         "autoprefixer": "^10.2.5",
         "axios": "^0.21.0",
         "babel-loader": "^8.2.1",
diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js
index 65b467bde7cbd8a3747999dec6adfe1abc5c4d35..5d11daadc55c6e0e8a7d67f4f73034f044c762ac 100644
--- a/resources/vue/base-components.js
+++ b/resources/vue/base-components.js
@@ -1,4 +1,5 @@
 const BaseComponents = {
+    CaptchaInput: () => import('./components/form_inputs/CaptchaInput.vue'),
     CalendarPermissionsTable: () => import("./components/form_inputs/CalendarPermissionsTable.vue"),
     DateListInput: () => import('./components/form_inputs/DateListInput.vue'),
     Datepicker: () => import('./components/Datepicker.vue'),
diff --git a/resources/vue/components/form_inputs/CaptchaInput.vue b/resources/vue/components/form_inputs/CaptchaInput.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1409aa88794ab66e6614ade3ae4c961c4d01db4d
--- /dev/null
+++ b/resources/vue/components/form_inputs/CaptchaInput.vue
@@ -0,0 +1,70 @@
+<template>
+    <div class="formpart">
+        <altcha-widget :challengeurl="challengeUrl" ref="widget"></altcha-widget>
+    </div>
+</template>
+<script>
+import 'altcha';
+import { $gettext } from '../../../assets/javascripts/lib/gettext';
+
+export default {
+    name: 'CaptchaInput',
+    props: {
+        name: {
+            type: String,
+            default: 'altcha'
+        },
+        challengeUrl: {
+            type: String,
+            requird: true,
+        },
+        auto: {
+            type: String,
+            default: null,
+            validator: (value) => ['onfocus', 'onload', 'onsubmit'].includes(value),
+        }
+    },
+    data() {
+        return {};
+    },
+    methods: {
+    },
+    mounted() {
+        this.$nextTick(() => {
+            this.$refs.widget.configure({
+                auto: this.auto,
+                name: this.name,
+                hidefooter: false,
+                hidelogo: false,
+                strings: {
+                    error: $gettext('Überprüfung fehlgeschlagen. Versuchen Sie es später erneut.'),
+                    footer: $gettext('Geschützt von <a href="https://altcha.org/" target="_blank">ALTCHA</a>'),
+                    label: $gettext('Ich bin kein Bot'),
+                    verified: $gettext('Überprüft'),
+                    verifying: $gettext('Überprüfung...'),
+                    waitAlert: $gettext('Überprüfung... Bitte warten.'),
+                },
+            });
+
+            this.$refs.widget.addEventListener('statechange', (ev) => {
+                if (ev.detail.state === 'verified') {
+                    this.$emit('input', ev.detail.payload);
+                }
+            })
+        });
+    }
+}
+</script>
+<style>
+:root {
+    --altcha-border-width: 0;
+    --altcha-border-radius: 0;
+    --altcha-color-base: transparent;
+    --altcha-color-border: #a0a0a0;
+    --altcha-color-text: currentColor;
+    --altcha-color-border-focus: currentColor;
+    --altcha-color-error-text: var(--red);
+    --altcha-color-footer-bg: none;
+    --altcha-max-width: auto;
+}
+</style>
diff --git a/templates/forms/form.php b/templates/forms/form.php
index 4745225414db9e1858bb65d4cdb8a543d2b4543d..fe19404e67dea6eea527350e565f1b49a2fb3af9 100644
--- a/templates/forms/form.php
+++ b/templates/forms/form.php
@@ -1,4 +1,8 @@
-<?
+<?php
+/**
+ * @var \Studip\Forms\Form $form
+ */
+
 $inputs = [];
 $allinputs = $form->getAllInputs();
 $required_inputs = [];