diff --git a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Element/MfaInfoElement.ts b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Element/MfaInfoElement.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1dc62390ffc598a259103dc92a0765e37b266595
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Element/MfaInfoElement.ts
@@ -0,0 +1,167 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
+import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
+import DocumentService = require('TYPO3/CMS/Core/DocumentService');
+import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
+import Notification = require('TYPO3/CMS/Backend/Notification');
+import Modal = require('TYPO3/CMS/Backend/Modal');
+import {SeverityEnum} from 'TYPO3/CMS/Backend/Enum/Severity';
+
+interface FieldOptions {
+  userId: number,
+  tableName: string
+}
+
+interface Response {
+  success: boolean;
+  status: Array<Status>;
+  remaining: number;
+}
+
+interface Status {
+  title: string;
+  message: string;
+}
+
+enum Selectors {
+  deactivteProviderButton = '.t3js-deactivate-provider-button',
+  deactivteMfaButton = '.t3js-deactivate-mfa-button',
+  providerslist = '.t3js-mfa-active-providers-list',
+  mfaStatusLabel = '.t3js-mfa-status-label',
+}
+
+class MfaInfoElement {
+  private options: FieldOptions = null;
+  private fullElement: HTMLElement = null;
+  private deactivteProviderButtons: NodeListOf<HTMLButtonElement> = null;
+  private deactivteMfaButton: HTMLButtonElement = null;
+  private providersList: HTMLUListElement = null;
+  private mfaStatusLabel: HTMLSpanElement = null;
+  private request: AjaxRequest = null;
+
+  constructor(selector: string, options: FieldOptions) {
+    this.options = options;
+    DocumentService.ready().then((document: Document): void => {
+      this.fullElement = document.querySelector(selector);
+      this.deactivteProviderButtons = this.fullElement.querySelectorAll(Selectors.deactivteProviderButton);
+      this.deactivteMfaButton = this.fullElement.querySelector(Selectors.deactivteMfaButton);
+      this.providersList = this.fullElement.querySelector(Selectors.providerslist);
+      this.mfaStatusLabel = this.fullElement.parentElement.querySelector(Selectors.mfaStatusLabel);
+      this.registerEvents();
+    });
+  }
+
+  private registerEvents(): void {
+    new RegularEvent('click', (e: Event): void => {
+      e.preventDefault();
+      this.prepareDeactivateRequest(this.deactivteMfaButton);
+    }).bindTo(this.deactivteMfaButton);
+
+    this.deactivteProviderButtons.forEach((buttonElement: HTMLButtonElement): void => {
+      new RegularEvent('click', (e: Event): void => {
+        e.preventDefault();
+        this.prepareDeactivateRequest(buttonElement);
+      }).bindTo(buttonElement);
+    });
+  }
+
+  private prepareDeactivateRequest(button: HTMLButtonElement): void {
+    const $modal = Modal.show(
+      button.dataset.confirmationTitle || button.getAttribute('title') || 'Deactivate provider(s)',
+      button.dataset.confirmationContent || 'Are you sure you want to continue? This action cannot be undone and will be applied immediately!',
+      SeverityEnum.warning,
+      [
+        {
+          text: button.dataset.confirmationCancelText || 'Cancel',
+          active: true,
+          btnClass: 'btn-default',
+          name: 'cancel'
+        },
+        {
+          text: button.dataset.confirmationDeactivateText || 'Deactivate',
+          btnClass: 'btn-warning',
+          name: 'deactivate',
+          trigger: (): void => {
+            this.sendDeactivateRequest(button.dataset.provider);
+          }
+        }
+      ]
+    );
+
+    $modal.on('button.clicked', (): void => {
+      $modal.modal('hide');
+    });
+  }
+
+  private sendDeactivateRequest(provider?: string): void {
+    if (this.request instanceof AjaxRequest) {
+      this.request.abort();
+    }
+    this.request = (new AjaxRequest(TYPO3.settings.ajaxUrls.mfa));
+    this.request.post({
+      action: 'deactivate',
+      provider: provider,
+      userId: this.options.userId,
+      tableName: this.options.tableName
+    }).then(async (response: AjaxResponse): Promise<any> => {
+      const data: Response = await response.resolve();
+      if (data.status.length > 0) {
+        data.status.forEach((status: Status): void => {
+          if (data.success) {
+            Notification.success(status.title, status.message);
+          } else {
+            Notification.error(status.title, status.message);
+          }
+        });
+      }
+      if (!data.success) {
+        return;
+      }
+      if (provider === undefined || data.remaining === 0) {
+        this.deactivateMfa();
+        return;
+      }
+      if (this.providersList === null) {
+        return;
+      }
+      const providerEntry: HTMLLIElement = this.providersList.querySelector('li#provider-' + provider);
+      if (providerEntry === null) {
+        return;
+      }
+      providerEntry.remove();
+      const providerEntries: NodeListOf<HTMLLIElement> = this.providersList.querySelectorAll('li');
+      if (providerEntries.length === 0){
+        this.deactivateMfa();
+      }
+    }).finally((): void => {
+      this.request = null;
+    });
+  }
+
+  private deactivateMfa(): void {
+    this.deactivteMfaButton.classList.add('disabled');
+    this.deactivteMfaButton.setAttribute('disabled', 'disabled');
+    if (this.providersList !== null) {
+      this.providersList.remove();
+    }
+    if (this.mfaStatusLabel !== null) {
+      this.mfaStatusLabel.innerText = this.mfaStatusLabel.dataset.alternativeLabel;
+      this.mfaStatusLabel.classList.remove('label-success');
+      this.mfaStatusLabel.classList.add('label-danger');
+    }
+  }
+}
+
+export = MfaInfoElement;
diff --git a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts
index 7901567a016798cf045da9f60c552a8ababb3891..b7b40bd16611b9ee84386496911c44f020673032 100644
--- a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts
+++ b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Viewport.ts
@@ -36,7 +36,9 @@ class Viewport {
     this.Topbar = new Topbar();
     this.NavigationContainer = new NavigationContainer(this.consumerScope, navigationSwitcher);
     this.ContentContainer = new ContentContainer(this.consumerScope);
-    this.NavigationContainer.setWidth(<number>Persistent.get('navigation.width'));
+    if (document.querySelector(ScaffoldIdentifierEnum.contentNavigation)) {
+      this.NavigationContainer.setWidth(<number>Persistent.get('navigation.width'));
+    }
     window.addEventListener('resize', this.fallbackNavigationSizeIfNeeded, {passive: true});
     if (navigationSwitcher) {
       navigationSwitcher.addEventListener('mouseup', this.toggleNavigation, {passive: true});
diff --git a/composer.json b/composer.json
index 8471a49a26cda236e5f94caba9c910e43a71fc64..3f791a9740c71e0966519e09b0715da37a2ea5e9 100644
--- a/composer.json
+++ b/composer.json
@@ -38,6 +38,8 @@
 		"ext-pcre": "*",
 		"ext-session": "*",
 		"ext-xml": "*",
+		"bacon/bacon-qr-code": "^2.0",
+		"christian-riesen/base32": "^1.5",
 		"cogpowered/finediff": "~0.3.1",
 		"doctrine/annotations": "^1.11",
 		"doctrine/dbal": "^2.12",
diff --git a/composer.lock b/composer.lock
index 0988f524d56008b33b1a66f7e093acd27a88daa6..ecdaeff89dacc3ba9ee4785b7502bd428fa8f223 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,120 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "a99ee20025ffbdb217cbcff8bedf3871",
+    "content-hash": "b4c3074ccc16c2e62a19f8ccba3f0998",
     "packages": [
+        {
+            "name": "bacon/bacon-qr-code",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Bacon/BaconQrCode.git",
+                "reference": "3e9d791b67d0a2912922b7b7c7312f4b37af41e4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3e9d791b67d0a2912922b7b7c7312f4b37af41e4",
+                "reference": "3e9d791b67d0a2912922b7b7c7312f4b37af41e4",
+                "shasum": ""
+            },
+            "require": {
+                "dasprid/enum": "^1.0.3",
+                "ext-iconv": "*",
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "phly/keep-a-changelog": "^1.4",
+                "phpunit/phpunit": "^7 | ^8 | ^9",
+                "squizlabs/php_codesniffer": "^3.4"
+            },
+            "suggest": {
+                "ext-imagick": "to generate QR code images"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "BaconQrCode\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ben Scholzen 'DASPRiD'",
+                    "email": "mail@dasprids.de",
+                    "homepage": "https://dasprids.de/",
+                    "role": "Developer"
+                }
+            ],
+            "description": "BaconQrCode is a QR code generator for PHP.",
+            "homepage": "https://github.com/Bacon/BaconQrCode",
+            "support": {
+                "issues": "https://github.com/Bacon/BaconQrCode/issues",
+                "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.3"
+            },
+            "time": "2020-10-30T02:02:47+00:00"
+        },
+        {
+            "name": "christian-riesen/base32",
+            "version": "1.5.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ChristianRiesen/base32.git",
+                "reference": "a1cac38d50adb5ce9337a62019a0697cc5da3ca1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ChristianRiesen/base32/zipball/a1cac38d50adb5ce9337a62019a0697cc5da3ca1",
+                "reference": "a1cac38d50adb5ce9337a62019a0697cc5da3ca1",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^2.17",
+                "phpstan/phpstan": "^0.12",
+                "phpunit/phpunit": "^8.5.13 || ^9.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Base32\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Riesen",
+                    "email": "chris.riesen@gmail.com",
+                    "homepage": "http://christianriesen.com",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Base32 encoder/decoder according to RFC 4648",
+            "homepage": "https://github.com/ChristianRiesen/base32",
+            "keywords": [
+                "base32",
+                "decode",
+                "encode",
+                "rfc4648"
+            ],
+            "support": {
+                "issues": "https://github.com/ChristianRiesen/base32/issues",
+                "source": "https://github.com/ChristianRiesen/base32/tree/1.5.2"
+            },
+            "time": "2021-01-11T22:44:02+00:00"
+        },
         {
             "name": "cogpowered/finediff",
             "version": "0.3.1",
@@ -61,6 +173,53 @@
             },
             "time": "2014-05-19T10:25:02+00:00"
         },
+        {
+            "name": "dasprid/enum",
+            "version": "1.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/DASPRiD/Enum.git",
+                "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/5abf82f213618696dda8e3bf6f64dd042d8542b2",
+                "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2",
+                "shasum": ""
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^7 | ^8 | ^9",
+                "squizlabs/php_codesniffer": "^3.4"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "DASPRiD\\Enum\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ben Scholzen 'DASPRiD'",
+                    "email": "mail@dasprids.de",
+                    "homepage": "https://dasprids.de/",
+                    "role": "Developer"
+                }
+            ],
+            "description": "PHP 7.1 enum implementation",
+            "keywords": [
+                "enum",
+                "map"
+            ],
+            "support": {
+                "issues": "https://github.com/DASPRiD/Enum/issues",
+                "source": "https://github.com/DASPRiD/Enum/tree/1.0.3"
+            },
+            "time": "2020-10-02T16:03:48+00:00"
+        },
         {
             "name": "doctrine/annotations",
             "version": "1.11.1",
diff --git a/typo3/sysext/backend/Classes/Controller/AbstractMfaController.php b/typo3/sysext/backend/Classes/Controller/AbstractMfaController.php
new file mode 100644
index 0000000000000000000000000000000000000000..120f0876a19f5ede6ca24a255d31c2384928ebe4
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Controller/AbstractMfaController.php
@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Backend\Template\ModuleTemplate;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Mvc\View\ViewInterface;
+
+/**
+ * Abstract class for mfa controllers (configuration and authentication)
+ *
+ * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
+ */
+abstract class AbstractMfaController
+{
+    protected ModuleTemplate $moduleTemplate;
+    protected UriBuilder $uriBuilder;
+    protected MfaProviderRegistry $mfaProviderRegistry;
+    protected array $mfaTsConfig;
+    protected bool $mfaRequired;
+    protected array $allowedProviders;
+    protected array $allowedActions = [];
+    protected ?MfaProviderManifestInterface $mfaProvider = null;
+    protected ?ViewInterface $view = null;
+
+    public function __construct(
+        ModuleTemplate $moduleTemplate,
+        UriBuilder $uriBuilder,
+        MfaProviderRegistry $mfaProviderRegistry
+    ) {
+        $this->moduleTemplate = $moduleTemplate;
+        $this->uriBuilder = $uriBuilder;
+        $this->mfaProviderRegistry = $mfaProviderRegistry;
+        $this->initializeMfaConfiguration();
+    }
+
+    /**
+     * Main action for handling the request and returning the response
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    abstract public function handleRequest(ServerRequestInterface $request): ResponseInterface;
+
+    protected function isActionAllowed(string $action): bool
+    {
+        return in_array($action, $this->allowedActions, true);
+    }
+
+    protected function isProviderAllowed(string $identifier): bool
+    {
+        return isset($this->allowedProviders[$identifier]);
+    }
+
+    protected function isValidIdentifier(string $identifier): bool
+    {
+        return $identifier !== ''
+            && $this->isProviderAllowed($identifier)
+            && $this->mfaProviderRegistry->hasProvider($identifier);
+    }
+
+    public function getLocalizedProviderTitle(): string
+    {
+        return $this->mfaProvider !== null ? $this->getLanguageService()->sL($this->mfaProvider->getTitle()) : '';
+    }
+
+    /**
+     * Initialize MFA configuration based on TSconfig and global configuration
+     */
+    protected function initializeMfaConfiguration(): void
+    {
+        $this->mfaTsConfig = $this->getBackendUser()->getTSConfig()['auth.']['mfa.'] ?? [];
+
+        // Set up required state based on user TSconfig and global configuration
+        if (isset($this->mfaTsConfig['required'])) {
+            // user TSconfig overrules global configuration
+            $this->mfaRequired = (bool)($this->mfaTsConfig['required'] ?? false);
+        } else {
+            $globalConfig = (int)($GLOBALS['TYPO3_CONF_VARS']['BE']['requireMfa'] ?? 0);
+            if ($globalConfig <= 1) {
+                // 0 and 1 can directly be used by type-casting to boolean
+                $this->mfaRequired = (bool)$globalConfig;
+            } else {
+                // check the admin / non-admin options
+                $isAdmin = $this->getBackendUser()->isAdmin();
+                $this->mfaRequired = ($globalConfig === 2 && !$isAdmin) || ($globalConfig === 3 && $isAdmin);
+            }
+        }
+
+        // Set up allowed providers based on user TSconfig and user groupData
+        $this->allowedProviders = array_filter($this->mfaProviderRegistry->getProviders(), function ($identifier) {
+            return $this->getBackendUser()->check('mfa_providers', $identifier)
+                && !GeneralUtility::inList(($this->mfaTsConfig['disableProviders'] ?? ''), $identifier);
+        }, ARRAY_FILTER_USE_KEY);
+    }
+
+    protected function getBackendUser(): BackendUserAuthentication
+    {
+        return $GLOBALS['BE_USER'];
+    }
+
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php b/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php
index c2e3ceccf090b46cf8f1dc601f25f7da37038ab4..12e59c03eacad16ae23d038103a5779e0b9b1086 100644
--- a/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php
+++ b/typo3/sysext/backend/Classes/Controller/ContentElement/ElementInformationController.php
@@ -351,6 +351,11 @@ class ElementInformationController
                 continue;
             }
 
+            // @todo Add meaningful information for mfa field. For the time being we don't display anything at all.
+            if ($this->type === 'db' && $name === 'mfa' && in_array($this->table, ['be_users', 'fe_users'], true)) {
+                continue;
+            }
+
             // not a real field -> skip
             if ($this->type === 'file' && $name === 'fileinfo') {
                 continue;
diff --git a/typo3/sysext/backend/Classes/Controller/MfaAjaxController.php b/typo3/sysext/backend/Classes/Controller/MfaAjaxController.php
new file mode 100644
index 0000000000000000000000000000000000000000..9466a4afcf2b001a88dbddb7803627c9ff0c3c0a
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Controller/MfaAjaxController.php
@@ -0,0 +1,203 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Http\JsonResponse;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Messaging\FlashMessage;
+use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
+
+/**
+ * Controller to manipulate MFA providers via AJAX in the backend
+ *
+ * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
+ */
+class MfaAjaxController
+{
+    private const ALLOWED_ACTIONS = ['deactivate'];
+
+    protected MfaProviderRegistry $mfaProviderRegistry;
+    protected ?AbstractUserAuthentication $user = null;
+
+    public function __construct(MfaProviderRegistry $mfaProviderRegistry)
+    {
+        $this->mfaProviderRegistry = $mfaProviderRegistry;
+    }
+
+    /**
+     * Main entry point, checking prerequisite and dispatching to the requested action
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function handleRequest(ServerRequestInterface $request): ResponseInterface
+    {
+        $action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? '');
+
+        if (!in_array($action, self::ALLOWED_ACTIONS, true)) {
+            return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.invalidRequest')));
+        }
+
+        $userId = (int)($request->getParsedBody()['userId'] ?? 0);
+        $tableName = (string)($request->getParsedBody()['tableName'] ?? '');
+
+        if (!$userId || !in_array($tableName, ['be_users', 'fe_users'], true)) {
+            return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.invalidRequest')));
+        }
+
+        $this->user = $this->initializeUser($userId, $tableName);
+
+        if (!$this->isAllowedToPerformAction($action)) {
+            return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.insufficientPermissions')));
+        }
+
+        return new JsonResponse($this->{$action . 'Action'}($request));
+    }
+
+    /**
+     * Deactivate MFA providers
+     * If the request contains a provider, it will be deactivated.
+     * Otherwise all active providers are deactivated.
+     *
+     * @param ServerRequestInterface $request
+     * @return array
+     */
+    protected function deactivateAction(ServerRequestInterface $request): array
+    {
+        $lang = $this->getLanguageService();
+        $userName = (string)($this->user->user[$this->user->username_column] ?? '');
+        $providerToDeactivate = (string)($request->getParsedBody()['provider'] ?? '');
+
+        if ($providerToDeactivate === '') {
+            // In case no provider is given, try to deactivate all active providers
+            $providersToDeactivate = $this->mfaProviderRegistry->getActiveProviders($this->user);
+            if ($providersToDeactivate === []) {
+                return $this->getResponseData(false, $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providersNotDeactivated'));
+            }
+            foreach ($providersToDeactivate as $identifier => $provider) {
+                $propertyManager = MfaProviderPropertyManager::create($provider, $this->user);
+                if (!$provider->deactivate($request, $propertyManager)) {
+                    return $this->getResponseData(false, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotDeactivated'), $lang->sL($provider->getTitle())));
+                }
+            }
+            return $this->getResponseData(true, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providersDeactivated'), $userName));
+        }
+
+        if (!$this->mfaProviderRegistry->hasProvider($providerToDeactivate)) {
+            return $this->getResponseData(false, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotFound'), $providerToDeactivate));
+        }
+
+        $provider = $this->mfaProviderRegistry->getProvider($providerToDeactivate);
+        $propertyManager = MfaProviderPropertyManager::create($provider, $this->user);
+
+        if (!$provider->deactivate($request, $propertyManager)) {
+            return $this->getResponseData(false, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotDeactivated'), $lang->sL($provider->getTitle())));
+        }
+
+        return $this->getResponseData(true, sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerDeactivated'), $lang->sL($provider->getTitle()), $userName));
+    }
+
+    /**
+     * Initialize a user based on the table name
+     *
+     * @param int $userId
+     * @param string $tableName
+     * @return AbstractUserAuthentication
+     */
+    protected function initializeUser(int $userId, string $tableName): AbstractUserAuthentication
+    {
+        $user = $tableName === 'be_users'
+            ? GeneralUtility::makeInstance(BackendUserAuthentication::class)
+            : GeneralUtility::makeInstance(FrontendUserAuthentication::class);
+
+        $user->enablecolumns = ['deleted' => true];
+        $user->setBeUserByUid($userId);
+
+        return $user;
+    }
+
+    /**
+     * Prepare response data for a JSON response
+     *
+     * @param bool $success
+     * @param string $message
+     * @return array
+     */
+    protected function getResponseData(bool $success, string $message): array
+    {
+        return [
+            'success' => $success,
+            'status' => (new FlashMessageQueue('backend'))->enqueue(
+                new FlashMessage(
+                    $message,
+                    $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.' . ($success ? 'success' : 'error'))
+                )
+            ),
+            'remaining' => count($this->mfaProviderRegistry->getActiveProviders($this->user))
+        ];
+    }
+
+    /**
+     * Check if the current logged in user is allowed to perform
+     * the requested action on the selected user.
+     *
+     * @param string $action
+     * @return bool
+     */
+    protected function isAllowedToPerformAction(string $action): bool
+    {
+        if ($action === 'deactivate') {
+            $currentBackendUser = $this->getBackendUser();
+            // Only admins are allowed to deactivate providers
+            if (!$currentBackendUser->isAdmin()) {
+                return false;
+            }
+            // System maintainer checks are only required for backend users
+            if ($this->user instanceof BackendUserAuthentication) {
+                $systemMaintainer = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
+                $isCurrentBackendUserSystemMaintainer = in_array((int)$currentBackendUser->user[$currentBackendUser->userid_column], $systemMaintainer, true);
+                $isTargetUserSystemMaintainer = in_array((int)$this->user->user[$this->user->userid_column], $systemMaintainer, true);
+                // Providers from system maintainers can only be deactivated by system maintainers
+                if ($isTargetUserSystemMaintainer && !$isCurrentBackendUserSystemMaintainer) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        return false;
+    }
+
+    protected function getBackendUser(): BackendUserAuthentication
+    {
+        return $GLOBALS['BE_USER'];
+    }
+
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Controller/MfaConfigurationController.php b/typo3/sysext/backend/Classes/Controller/MfaConfigurationController.php
new file mode 100644
index 0000000000000000000000000000000000000000..dc688f1b16d2741cff39b56ad525e2b0c03eef55
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Controller/MfaConfigurationController.php
@@ -0,0 +1,432 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\UriInterface;
+use TYPO3\CMS\Backend\Template\Components\ButtonBar;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
+use TYPO3\CMS\Core\Http\HtmlResponse;
+use TYPO3\CMS\Core\Http\RedirectResponse;
+use TYPO3\CMS\Core\Imaging\Icon;
+use TYPO3\CMS\Core\Messaging\FlashMessage;
+use TYPO3\CMS\Core\Messaging\FlashMessageService;
+use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Fluid\View\StandaloneView;
+
+/**
+ * Controller to configure MFA providers in the backend
+ *
+ * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
+ */
+class MfaConfigurationController extends AbstractMfaController
+{
+    protected array $allowedActions = ['overview', 'setup', 'activate', 'deactivate', 'unlock', 'edit', 'save'];
+
+    /**
+     * Main entry point, checking prerequisite, initializing and setting
+     * up the view and finally dispatching to the requested action.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function handleRequest(ServerRequestInterface $request): ResponseInterface
+    {
+        $action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? 'overview');
+
+        if (!$this->isActionAllowed($action)) {
+            return new HtmlResponse('Action not allowed', 400);
+        }
+
+        $this->initializeAction($request);
+        // All actions expect "overview" require a provider to deal with.
+        // If non is found at this point, initiate a redirect to the overview.
+        if ($this->mfaProvider === null && $action !== 'overview') {
+            $this->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:providerNotFound'), '', FlashMessage::ERROR);
+            return new RedirectResponse($this->getActionUri('overview'));
+        }
+        $this->initializeView($action);
+
+        $result = $this->{$action . 'Action'}($request);
+        if ($result instanceof ResponseInterface) {
+            return $result;
+        }
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Setup the overview with all available MFA providers
+     *
+     * @param ServerRequestInterface $request
+     */
+    public function overviewAction(ServerRequestInterface $request): void
+    {
+        $this->addOverviewButtons($request);
+        $this->view->assignMultiple([
+            'providers' => $this->allowedProviders,
+            'defaultProvider' => $this->getDefaultProviderIdentifier(),
+            'recommendedProvider' => $this->getRecommendedProviderIdentifier(),
+            'setupRequired' => $this->mfaRequired && !$this->mfaProviderRegistry->hasActiveProviders($this->getBackendUser())
+        ]);
+    }
+
+    /**
+     * Render form to setup a provider by using provider specific content
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function setupAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $this->addFormButtons();
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser());
+        $providerResponse = $this->mfaProvider->handleRequest($request, $propertyManager, MfaViewType::SETUP);
+        $this->view->assignMultiple([
+            'provider' => $this->mfaProvider,
+            'providerContent' => $providerResponse->getBody()
+        ]);
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Handle activate request, receiving from the setup view
+     * by forwarding the request to the appropriate provider.
+     * Furthermore, add the provider as default provider in case
+     * it is the recommended provider for this user, or no default
+     * provider is yet defined the newly activated provider is allowed
+     * to be a default provider and there are no other providers which
+     * would suite as default provider.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function activateAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $backendUser = $this->getBackendUser();
+        $isRecommendedProvider = $this->getRecommendedProviderIdentifier() === $this->mfaProvider->getIdentifier();
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $backendUser);
+        if (!$this->mfaProvider->activate($request, $propertyManager)) {
+            $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:activate.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR);
+            return new RedirectResponse($this->getActionUri('setup', ['identifier' => $this->mfaProvider->getIdentifier()]));
+        }
+        if ($isRecommendedProvider
+            || (
+                $this->getDefaultProviderIdentifier() === ''
+                && $this->mfaProvider->isDefaultProviderAllowed()
+                && !$this->hasSuitableDefaultProviders([$this->mfaProvider->getIdentifier()])
+            )
+        ) {
+            $this->setDefaultProvider();
+        }
+        // If this is the first activated provider, the user has logged in without being required
+        // to pass the MFA challenge. Therefore no session entry exists. To prevent the challenge
+        // from showing up after the activation we need to set the session data here.
+        if (!(bool)($backendUser->getSessionData('mfa') ?? false)) {
+            $backendUser->setSessionData('mfa', true);
+        }
+        $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:activate.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK);
+        return new RedirectResponse($this->getActionUri('overview'));
+    }
+
+    /**
+     * Handle deactivate request by forwarding the request to the
+     * appropriate provider. Also remove the provider as default
+     * provider from user UC, if set.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function deactivateAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser());
+        if (!$this->mfaProvider->deactivate($request, $propertyManager)) {
+            $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:deactivate.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR);
+        } else {
+            if ($this->isDefaultProvider()) {
+                $this->removeDefaultProvider();
+            }
+            $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:deactivate.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK);
+        }
+        return new RedirectResponse($this->getActionUri('overview'));
+    }
+
+    /**
+     * Handle unlock request by forwarding the request to the appropriate provider
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function unlockAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser());
+        if (!$this->mfaProvider->unlock($request, $propertyManager)) {
+            $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:unlock.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR);
+        } else {
+            $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:unlock.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK);
+        }
+        return new RedirectResponse($this->getActionUri('overview'));
+    }
+
+    /**
+     * Render form to edit a provider by using provider specific content
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function editAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $this->addFormButtons();
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser());
+        $providerResponse = $this->mfaProvider->handleRequest($request, $propertyManager, MfaViewType::EDIT);
+        $this->view->assignMultiple([
+            'provider' => $this->mfaProvider,
+            'providerContent' => $providerResponse->getBody(),
+            'isDefaultProvider' => $this->isDefaultProvider()
+        ]);
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Handle save request, receiving from the edit view by
+     * forwarding the request to the appropriate provider.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function saveAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser());
+        if (!$this->mfaProvider->update($request, $propertyManager)) {
+            $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:save.failure'), $this->getLocalizedProviderTitle()), '', FlashMessage::ERROR);
+        } else {
+            if ((bool)($request->getParsedBody()['defaultProvider'] ?? false)) {
+                $this->setDefaultProvider();
+            } elseif ($this->isDefaultProvider()) {
+                $this->removeDefaultProvider();
+            }
+            $this->addFlashMessage(sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:save.success'), $this->getLocalizedProviderTitle()), '', FlashMessage::OK);
+        }
+        return new RedirectResponse($this->getActionUri('edit', ['identifier' => $this->mfaProvider->getIdentifier()]));
+    }
+
+    /**
+     * Initialize the action by fetching the requested provider by its identifier
+     *
+     * @param ServerRequestInterface $request
+     */
+    protected function initializeAction(ServerRequestInterface $request): void
+    {
+        $identifier = (string)($request->getQueryParams()['identifier'] ?? $request->getParsedBody()['identifier'] ?? '');
+        // Check if given identifier is valid
+        if ($this->isValidIdentifier($identifier)) {
+            $this->mfaProvider = $this->mfaProviderRegistry->getProvider($identifier);
+        }
+    }
+
+    /**
+     * Initialize the standalone view and set the template name
+     *
+     * @param string $templateName
+     */
+    protected function initializeView(string $templateName): void
+    {
+        $this->view = GeneralUtility::makeInstance(StandaloneView::class);
+        $this->view->setTemplateRootPaths(['EXT:backend/Resources/Private/Templates/Mfa']);
+        $this->view->setPartialRootPaths(['EXT:backend/Resources/Private/Partials']);
+        $this->view->setLayoutRootPaths(['EXT:backend/Resources/Private/Layouts']);
+        $this->view->setTemplate($templateName);
+    }
+
+    /**
+     * Build a uri for the current controller based on the
+     * given action, respecting additional parameters.
+     *
+     * @param string $action
+     * @param array  $additionalParameters
+     *
+     * @return UriInterface
+     */
+    protected function getActionUri(string $action, array $additionalParameters = []): UriInterface
+    {
+        if (!$this->isActionAllowed($action)) {
+            $action = 'overview';
+        }
+        return $this->uriBuilder->buildUriFromRoute('mfa', array_merge(['action' => $action], $additionalParameters));
+    }
+
+    /**
+     * Check if there are more suitable default providers for the current user
+     *
+     * @param array $excludedProviders
+     * @return bool
+     */
+    protected function hasSuitableDefaultProviders(array $excludedProviders = []): bool
+    {
+        foreach ($this->allowedProviders as $identifier => $provider) {
+            if (!in_array($identifier, $excludedProviders, true)
+                && $provider->isDefaultProviderAllowed()
+                && $provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser()))
+            ) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Get the default provider
+     *
+     * @return string The identifier of the default provider
+     */
+    protected function getDefaultProviderIdentifier(): string
+    {
+        $defaultProviderIdentifier = (string)($this->getBackendUser()->uc['mfa']['defaultProvider'] ?? '');
+        // The default provider value is only valid, if the corresponding provider exist and is allowed
+        if ($this->isValidIdentifier($defaultProviderIdentifier)) {
+            $defaultProvider = $this->mfaProviderRegistry->getProvider($defaultProviderIdentifier);
+            $propertyManager = MfaProviderPropertyManager::create($defaultProvider, $this->getBackendUser());
+            // Also check if the provider is activated for the user
+            if ($defaultProvider->isActive($propertyManager)) {
+                return $defaultProviderIdentifier;
+            }
+        }
+
+        // If the stored provider is not valid, clean up the UC
+        $this->removeDefaultProvider();
+        return '';
+    }
+
+    /**
+     * Get the recommended provider
+     *
+     * @return string The identifier of the recommended provider
+     */
+    protected function getRecommendedProviderIdentifier(): string
+    {
+        $recommendedProviderIdentifier = (string)($this->mfaTsConfig['recommendedProvider'] ?? '');
+        // Check if valid and allowed to be default provider, which is obviously a prerequisite
+        if (!$this->isValidIdentifier($recommendedProviderIdentifier)
+            || !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed()
+        ) {
+            // If the provider, defined in user TSconfig is not valid or is not set, check the globally defined
+            $recommendedProviderIdentifier = (string)($GLOBALS['TYPO3_CONF_VARS']['BE']['recommendedMfaProvider'] ?? '');
+            if (!$this->isValidIdentifier($recommendedProviderIdentifier)
+                || !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed()
+            ) {
+                // If also not valid or not set, return
+                return '';
+            }
+        }
+
+        $provider = $this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier);
+        $propertyManager = MfaProviderPropertyManager::create($provider, $this->getBackendUser());
+        // If the defined recommended provider is valid, check if it is not yet activated
+        return !$provider->isActive($propertyManager) ? $recommendedProviderIdentifier : '';
+    }
+
+    protected function isDefaultProvider(): bool
+    {
+        return $this->getDefaultProviderIdentifier() === $this->mfaProvider->getIdentifier();
+    }
+
+    protected function setDefaultProvider(): void
+    {
+        $this->getBackendUser()->uc['mfa']['defaultProvider'] = $this->mfaProvider->getIdentifier();
+        $this->getBackendUser()->writeUC();
+    }
+
+    protected function removeDefaultProvider(): void
+    {
+        $this->getBackendUser()->uc['mfa']['defaultProvider'] = '';
+        $this->getBackendUser()->writeUC();
+    }
+
+    protected function addFlashMessage(string $message, string $title = '', int $severity = FlashMessage::INFO): void
+    {
+        $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $message, $title, $severity, true);
+        $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
+        $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier();
+        $defaultFlashMessageQueue->enqueue($flashMessage);
+    }
+
+    protected function addOverviewButtons(ServerRequestInterface $request): void
+    {
+        $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
+
+        if (($returnUrl = $this->getReturnUrl($request)) !== '') {
+            $button = $buttonBar
+                ->makeLinkButton()
+                ->setHref($returnUrl)
+                ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-view-go-back', Icon::SIZE_SMALL))
+                ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.goBack'))
+                ->setShowLabelText(true);
+            $buttonBar->addButton($button);
+        }
+
+        $reloadButton = $buttonBar
+            ->makeLinkButton()
+            ->setHref($request->getAttribute('normalizedParams')->getRequestUri())
+            ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.reload'))
+            ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-refresh', Icon::SIZE_SMALL));
+        $buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT);
+    }
+
+    protected function addFormButtons(): void
+    {
+        $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
+        $lang = $this->getLanguageService();
+
+        $closeButton = $buttonBar
+            ->makeLinkButton()
+            ->setHref($this->uriBuilder->buildUriFromRoute('mfa', ['action' => 'overview']))
+            ->setClasses('t3js-editform-close')
+            ->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc'))
+            ->setShowLabelText(true)
+            ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-close', Icon::SIZE_SMALL));
+        $buttonBar->addButton($closeButton);
+
+        $saveButton = $buttonBar
+            ->makeInputButton()
+            ->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.saveDoc'))
+            ->setName('save')
+            ->setValue('1')
+            ->setShowLabelText(true)
+            ->setForm('mfaConfigurationController')
+            ->setIcon($this->moduleTemplate->getIconFactory()->getIcon('actions-document-save', Icon::SIZE_SMALL));
+        $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
+    }
+
+    protected function getReturnUrl(ServerRequestInterface $request): string
+    {
+        $returnUrl = GeneralUtility::sanitizeLocalUrl(
+            $request->getQueryParams()['returnUrl'] ?? $request->getParsedBody()['returnUrl'] ?? ''
+        );
+
+        if ($returnUrl === '' && ExtensionManagementUtility::isLoaded('setup')) {
+            $returnUrl = (string)$this->uriBuilder->buildUriFromRoute('user_setup');
+        }
+
+        return $returnUrl;
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Controller/MfaController.php b/typo3/sysext/backend/Classes/Controller/MfaController.php
new file mode 100644
index 0000000000000000000000000000000000000000..2256dfdb4199f189da38289f5bb00cb47a05449e
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Controller/MfaController.php
@@ -0,0 +1,201 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface;
+use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
+use TYPO3\CMS\Core\Http\HtmlResponse;
+use TYPO3\CMS\Core\Http\RedirectResponse;
+
+/**
+ * Controller to provide a multi-factor authentication endpoint
+ *
+ * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
+ */
+class MfaController extends AbstractMfaController implements LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    protected array $allowedActions = ['auth', 'verify', 'cancel'];
+
+    /**
+     * Main entry point, checking prerequisite, initializing and setting
+     * up the view and finally dispatching to the requested action.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function handleRequest(ServerRequestInterface $request): ResponseInterface
+    {
+        $action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? 'auth');
+
+        if (!$this->isActionAllowed($action)) {
+            throw new \InvalidArgumentException('Action not allowed', 1611879243);
+        }
+
+        $this->initializeAction($request);
+        // All actions expect "cancel" require a provider to deal with.
+        // If non is found at this point, throw an exception since this should never happen.
+        if ($this->mfaProvider === null && $action !== 'cancel') {
+            throw new \InvalidArgumentException('No active MFA provider was found!', 1611879242);
+        }
+
+        $this->view = $this->moduleTemplate->getView();
+        $this->view->setTemplateRootPaths(['EXT:backend/Resources/Private/Templates/Mfa']);
+        $this->view->setTemplate('Auth');
+        $this->view->assign('hasAuthError', (bool)($request->getQueryParams()['failure'] ?? false));
+
+        $result = $this->{$action . 'Action'}($request);
+        if ($result instanceof ResponseInterface) {
+            return $result;
+        }
+        $this->moduleTemplate->setTitle('TYPO3 CMS Login: ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']);
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Setup the authentication view for the provider by using provider specific content
+     *
+     * @param ServerRequestInterface $request
+     */
+    public function authAction(ServerRequestInterface $request): void
+    {
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser());
+        $providerResponse = $this->mfaProvider->handleRequest($request, $propertyManager, MfaViewType::AUTH);
+        $this->view->assignMultiple([
+            'provider' => $this->mfaProvider,
+            'alternativeProviders' => $this->getAlternativeProviders(),
+            'isLocked' => $this->mfaProvider->isLocked($propertyManager),
+            'providerContent' => $providerResponse->getBody()
+        ]);
+    }
+
+    /**
+     * Handle verification request, receiving from the auth view
+     * by forwarding the request to the appropriate provider.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     * @throws RouteNotFoundException
+     */
+    public function verifyAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $propertyManager = MfaProviderPropertyManager::create($this->mfaProvider, $this->getBackendUser());
+
+        // Check if the provider can process the request and is not temporarily blocked
+        if (!$this->mfaProvider->canProcess($request) || $this->mfaProvider->isLocked($propertyManager)) {
+            // If this fails, cancel the authentication
+            return $this->cancelAction($request);
+        }
+        // Call the provider to verify the request
+        if (!$this->mfaProvider->verify($request, $propertyManager)) {
+            $this->log('Multi-factor authentication failed');
+            // If failed, initiate a redirect back to the auth view
+            return new RedirectResponse($this->uriBuilder->buildUriFromRoute(
+                'auth_mfa',
+                [
+                    'identifier' => $this->mfaProvider->getIdentifier(),
+                    'failure' => true
+                ]
+            ));
+        }
+        $this->log('Multi-factor authentication successfull');
+        // If verified, store this information in the session
+        // and initiate a redirect back to the login view.
+        $this->getBackendUser()->setAndSaveSessionData('mfa', true);
+        return new RedirectResponse($this->uriBuilder->buildUriFromRoute('login'));
+    }
+
+    /**
+     * Allow the user to cancel the multi-factor authentication by
+     * calling logoff on the user object, to destroy the session and
+     * other already gathered information and finally initiate a
+     * redirect back to the login.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     * @throws RouteNotFoundException
+     */
+    public function cancelAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $this->log('Multi-factor authentication canceled');
+        $this->getBackendUser()->logoff();
+        return new RedirectResponse($this->uriBuilder->buildUriFromRoute('login'));
+    }
+
+    /**
+     * Initialize the action by fetching the requested provider by its identifier
+     *
+     * @param ServerRequestInterface $request
+     */
+    protected function initializeAction(ServerRequestInterface $request): void
+    {
+        $identifier = (string)($request->getQueryParams()['identifier'] ?? $request->getParsedBody()['identifier'] ?? '');
+        // Check if given identifier is valid
+        if ($this->isValidIdentifier($identifier)) {
+            $provider = $this->mfaProviderRegistry->getProvider($identifier);
+            // Only add provider if it was activated by the current user
+            if ($provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser()))) {
+                $this->mfaProvider = $provider;
+            }
+        }
+    }
+
+    /**
+     * Fetch alternative (activated and allowed) providers for the user to chose from
+     *
+     * @return ProviderInterface[]
+     */
+    protected function getAlternativeProviders(): array
+    {
+        return array_filter($this->allowedProviders, function ($provider) {
+            return $provider !== $this->mfaProvider
+                && $provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser()));
+        });
+    }
+
+    /**
+     * Log debug information for MFA events
+     *
+     * @param string $message
+     * @param array $additionalData
+     */
+    protected function log(string $message, array $additionalData = []): void
+    {
+        $user = $this->getBackendUser();
+        $context = [
+            'user' => [
+                'uid' => $user->user[$user->userid_column],
+                'username' => $user->user[$user->username_column]
+            ]
+        ];
+        if ($this->mfaProvider !== null) {
+            $context['provider'] = $this->mfaProvider->getIdentifier();
+            $context['isProviderLocked'] = $this->mfaProvider->isLocked(
+                MfaProviderPropertyManager::create($this->mfaProvider, $user)
+            );
+        }
+        $this->logger->debug($message, array_replace_recursive($context, $additionalData));
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Form/Element/MfaInfoElement.php b/typo3/sysext/backend/Classes/Form/Element/MfaInfoElement.php
new file mode 100644
index 0000000000000000000000000000000000000000..d4e931b266d1b1ec822024c9c6fa85321cda01e7
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Form/Element/MfaInfoElement.php
@@ -0,0 +1,181 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Form\Element;
+
+use TYPO3\CMS\Backend\Form\NodeFactory;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Imaging\Icon;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
+
+/**
+ * Renders an element, displaying MFA related information and providing
+ * interactions like deactivation of active providers and MFA in general.
+ *
+ * @internal
+ */
+class MfaInfoElement extends AbstractFormElement
+{
+    private const ALLOWED_TABLES = ['be_users', 'fe_users'];
+
+    protected MfaProviderRegistry $mfaProviderRegistry;
+
+    public function __construct(NodeFactory $nodeFactory, array $data)
+    {
+        parent::__construct($nodeFactory, $data);
+        $this->mfaProviderRegistry = GeneralUtility::makeInstance(MfaProviderRegistry::class);
+    }
+
+    public function render(): array
+    {
+        $resultArray = $this->initializeResultArray();
+        $currentBackendUser = $this->getBackendUser();
+        $tableName = $this->data['tableName'];
+
+        // This renderType only works for user tables: be_users, fe_users
+        if (!in_array($tableName, self::ALLOWED_TABLES, true)) {
+            return $resultArray;
+        }
+
+        // Initialize a user based on the current table name
+        $targetUser = $tableName === 'be_users'
+            ? GeneralUtility::makeInstance(BackendUserAuthentication::class)
+            : GeneralUtility::makeInstance(FrontendUserAuthentication::class);
+
+        $userId = (int)($this->data['databaseRow'][$targetUser->userid_column] ?? 0);
+        $targetUser->enablecolumns = ['deleted' => true];
+        $targetUser->setBeUserByUid($userId);
+
+        $isDeactivationAllowed = true;
+        // System maintainer checks are only required for backend users
+        if ($targetUser instanceof BackendUserAuthentication) {
+            $systemMaintainer = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
+            $isCurrentBackendUserSystemMaintainer = in_array((int)$currentBackendUser->user[$currentBackendUser->userid_column], $systemMaintainer, true);
+            $isTargetUserSystemMaintainer = in_array((int)$targetUser->user[$targetUser->userid_column], $systemMaintainer, true);
+            // Providers from system maintainers can only be deactivated by system maintainers
+            if ($isTargetUserSystemMaintainer && !$isCurrentBackendUserSystemMaintainer) {
+                $isDeactivationAllowed = false;
+            }
+        }
+
+        // Fetch providers from the mfa field
+        $mfaProviders = json_decode($this->data['parameterArray']['itemFormElValue'] ?? '', true) ?? [];
+
+        // Initialize variables
+        $html = $childHtml = $activeProviders = $lockedProviders = [];
+        $lang = $this->getLanguageService();
+        $enabledLabel = htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.mfa.enabled'));
+        $disabledLabel = htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.mfa.disabled'));
+        $status = '<span class="label label-danger label-space-right t3js-mfa-status-label" data-alternative-label="' . $enabledLabel . '">' . $disabledLabel . '</span>';
+
+        // Unset invalid providers
+        foreach ($mfaProviders as $identifier => $providerSettings) {
+            if (!$this->mfaProviderRegistry->hasProvider($identifier)) {
+                unset($mfaProviders[$identifier]);
+            }
+        }
+
+        if ($mfaProviders !== []) {
+            // Check if remaining providers are active and/or locked for the user
+            foreach ($mfaProviders as $identifier => $providerSettings) {
+                $provider = $this->mfaProviderRegistry->getProvider($identifier);
+                $propertyManager = MfaProviderPropertyManager::create($provider, $targetUser);
+                if (!$provider->isActive($propertyManager)) {
+                    continue;
+                }
+                $activeProviders[$identifier] = $provider;
+                if ($provider->isLocked($propertyManager)) {
+                    $lockedProviders[] = $identifier;
+                }
+            }
+
+            if ($activeProviders !== []) {
+                // Change status label to MFA being enabled
+                $status = '<span class="label label-success label-space-right t3js-mfa-status-label"' . ' data-alternative-label="' . $disabledLabel . '">' . $enabledLabel . '</span>';
+
+                // Add providers list
+                $childHtml[] = '<ul class="list-group t3js-mfa-active-providers-list">';
+                foreach ($activeProviders as $identifier => $activeProvider) {
+                    $childHtml[] = '<li class="list-group-item" id="provider-' . htmlspecialchars($identifier) . '" style="line-height: 2.1em;">';
+                    $childHtml[] =  $this->iconFactory->getIcon($activeProvider->getIconIdentifier(), Icon::SIZE_SMALL);
+                    $childHtml[] =  htmlspecialchars($lang->sL($activeProvider->getTitle()));
+                    if (in_array($identifier, $lockedProviders, true)) {
+                        $childHtml[] = '<span class="label label-danger">' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.locked')) . '</span>';
+                    } else {
+                        $childHtml[] = '<span class="label label-success">' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.active')) . '</span>';
+                    }
+                    if ($isDeactivationAllowed) {
+                        $childHtml[] = '<button type="button"';
+                        $childHtml[] =  ' class="btn btn-default btn-sm pull-right t3js-deactivate-provider-button"';
+                        $childHtml[] =  ' data-confirmation-title="' . htmlspecialchars(sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfaProvider'), $lang->sL($activeProvider->getTitle()))) . '"';
+                        $childHtml[] =  ' data-confirmation-content="' . htmlspecialchars(sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfaProvider.confirmation.text'), $lang->sL($activeProvider->getTitle()))) . '"';
+                        $childHtml[] =  ' data-confirmation-cancel-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel')) . '"';
+                        $childHtml[] =  ' data-confirmation-deactivate-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.deactivate')) . '"';
+                        $childHtml[] =  ' data-provider="' . htmlspecialchars($identifier) . '"';
+                        $childHtml[] =  ' title="' . htmlspecialchars(sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfaProvider'), $lang->sL($activeProvider->getTitle()))) . '"';
+                        $childHtml[] =  '>';
+                        $childHtml[] =      $this->iconFactory->getIcon('actions-delete', Icon::SIZE_SMALL)->render('inline');
+                        $childHtml[] = '</button>';
+                    }
+                    $childHtml[] = '</li>';
+                }
+                $childHtml[] = '</ul>';
+            }
+        }
+
+        $fieldId = 't3js-form-field-mfa-id' . StringUtility::getUniqueId('-');
+
+        $html[] = '<div class="formengine-field-item t3js-formengine-field-item" id="' . htmlspecialchars($fieldId) . '">';
+        $html[] =   '<div class="form-control-wrap" style="max-width: ' . (int)$this->formMaxWidth($this->defaultInputWidth) . 'px">';
+        $html[] =       '<div class="form-wizards-wrap">';
+        $html[] =           '<div class="form-wizards-element">';
+        $html[] =               implode(PHP_EOL, $childHtml);
+        if ($isDeactivationAllowed) {
+            $html[] = '<div class="form-wizards-items-bottom">';
+            $html[] =   '<div class="help-block">';
+            $html[] =       '<button type="button"';
+            $html[] =           ' class="t3js-deactivate-mfa-button btn btn-danger ' . ($activeProviders === [] ? 'disabled" disabled="disabled' : '') . '"';
+            $html[] =           ' data-confirmation-title="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfa')) . '"';
+            $html[] =           ' data-confirmation-content="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfa.confirmation.text')) . '"';
+            $html[] =           ' data-confirmation-cancel-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel')) . '"';
+            $html[] =           ' data-confirmation-deactivate-text="' . htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.deactivate')) . '"';
+            $html[] =       '>';
+            $html[] =           $this->iconFactory->getIcon('actions-toggle-off', Icon::SIZE_SMALL)->render('inline');
+            $html[] =           htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.deactivateMfa'));
+            $html[] =       '</button>';
+            $html[] =   '</div>';
+            $html[] = '</div>';
+        }
+        $html[] =           '</div>';
+        $html[] =       '</div>';
+        $html[] =   '</div>';
+        $html[] = '</div>';
+
+        $resultArray['requireJsModules'][] = ['TYPO3/CMS/Backend/FormEngine/Element/MfaInfoElement' => '
+            function(MfaInfoElement) {
+                new MfaInfoElement(' . GeneralUtility::quoteJSvalue('#' . $fieldId) . ', ' . json_encode(['userId' => $userId, 'tableName' => $tableName]) . ');
+            }'
+        ];
+
+        $resultArray['html'] = $status . implode(PHP_EOL, $html);
+        return $resultArray;
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Form/NodeFactory.php b/typo3/sysext/backend/Classes/Form/NodeFactory.php
index e4bf7a72a76c0afcda61cc64e42aeb662112918d..4ca65ab47f60e026bb2a176b45fef4f9c19f78c1 100644
--- a/typo3/sysext/backend/Classes/Form/NodeFactory.php
+++ b/typo3/sysext/backend/Classes/Form/NodeFactory.php
@@ -93,6 +93,7 @@ class NodeFactory
         // special renderType for type="user" on sys_file_storage is_public column
         'userSysFileStorageIsPublic' => Element\UserSysFileStorageIsPublicElement::class,
         'fileInfo' => Element\FileInfoElement::class,
+        'mfaInfo' => Element\MfaInfoElement::class,
         'slug' => Element\InputSlugElement::class,
         'passthrough' => Element\PassThroughElement::class,
 
diff --git a/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php b/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php
index 2ca5bd0e778cbb321214fa3b6950749982a61246..c204b1be20fc870a39db61cee10d484a73613118 100644
--- a/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php
+++ b/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php
@@ -23,6 +23,8 @@ use Psr\Http\Server\RequestHandlerInterface;
 use TYPO3\CMS\Backend\Routing\Route;
 use TYPO3\CMS\Backend\Routing\UriBuilder;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException;
 use TYPO3\CMS\Core\Controller\ErrorPageController;
 use TYPO3\CMS\Core\Http\HtmlResponse;
 use TYPO3\CMS\Core\Http\RedirectResponse;
@@ -72,7 +74,16 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
         // The global must be available very early, because methods below
         // might trigger code which relies on it. See: #45625
         $GLOBALS['BE_USER'] = GeneralUtility::makeInstance(BackendUserAuthentication::class);
-        $GLOBALS['BE_USER']->start();
+        try {
+            $GLOBALS['BE_USER']->start();
+        } catch (MfaRequiredException $mfaRequiredException) {
+            // If MFA is required and we are not already on the "auth_mfa"
+            // route, force the user to it for further authentication
+            if ($route->getOption('_identifier') !== 'auth_mfa') {
+                return $this->redirectToMfaAuthProcess($GLOBALS['BE_USER'], $mfaRequiredException->getProvider());
+            }
+        }
+
         // Register the backend user as aspect and initializing workspace once for TSconfig conditions
         $this->setBackendUserAspect($GLOBALS['BE_USER'], (int)$GLOBALS['BE_USER']->user['workspace_id']);
         if ($this->isLoggedInBackendUserRequired($route)) {
@@ -138,6 +149,30 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
         UserSessionManager::create('BE')->collectGarbage();
     }
 
+    /**
+     * Initiate a redirect to the auth_mfa route with the given
+     * provider and necessary cookies and headers appended.
+     *
+     * @param BackendUserAuthentication $user
+     * @param MfaProviderManifestInterface $provider
+     * @return ResponseInterface
+     */
+    protected function redirectToMfaAuthProcess(
+        BackendUserAuthentication $user,
+        MfaProviderManifestInterface $provider
+    ): ResponseInterface {
+        // GLOBALS[LANG] needs to be set up, because the UriBuilder is generating a token, which in turn
+        // needs the FormProtectionFactory, which then builds a Message Closure with GLOBALS[LANG] (hacky, yes!)
+        $GLOBALS['LANG'] = LanguageService::createFromUserPreferences($user);
+        $uri = GeneralUtility::makeInstance(UriBuilder::class)
+            ->buildUriFromRoute('auth_mfa', ['identifier' => $provider->getIdentifier()]);
+        $response = new RedirectResponse($uri);
+        // Add necessary cookies and headers to the response so
+        // the already passed authentication step is not lost.
+        $response = $user->appendCookieToResponse($response);
+        $response = $this->applyHeadersToResponse($response);
+        return $response;
+    }
     /**
      * Check if the user is required for the request.
      * If we're trying to do a login or an ajax login, don't require a user.
diff --git a/typo3/sysext/backend/Classes/ViewHelpers/Mfa/IfHasStateViewHelper.php b/typo3/sysext/backend/Classes/ViewHelpers/Mfa/IfHasStateViewHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..c0d02e72780b2be175c1ae99ddf6c8a29306181e
--- /dev/null
+++ b/typo3/sysext/backend/Classes/ViewHelpers/Mfa/IfHasStateViewHelper.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\ViewHelpers\Mfa;
+
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
+use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractConditionViewHelper;
+
+/**
+ * Check if the given provider for the current user has the requested state set
+ *
+ * @internal
+ */
+class IfHasStateViewHelper extends AbstractConditionViewHelper
+{
+    public function initializeArguments(): void
+    {
+        parent::initializeArguments();
+        $this->registerArgument('state', 'string', 'The state to check for (e.g. active or locked)', true);
+        $this->registerArgument('provider', MfaProviderManifestInterface::class, 'The provider in question', true);
+    }
+
+    public static function verdict(array $arguments, RenderingContextInterface $renderingContext): bool
+    {
+        $stateMethod = 'is' . ucfirst($arguments['state']);
+        $provider = $arguments['provider'];
+
+        $propertyManager = MfaProviderPropertyManager::create($provider, $GLOBALS['BE_USER']);
+        return is_callable([$provider, $stateMethod]) && $provider->{$stateMethod}($propertyManager);
+    }
+}
diff --git a/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php b/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php
index 83e309256556b1001159028561ef9110f9320e75..8667641d1bf4a4e4ae98bf520b624a18474084ce 100644
--- a/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php
+++ b/typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php
@@ -206,6 +206,12 @@ return [
         ]
     ],
 
+    // Multi-factor authentication configuration
+    'mfa' => [
+        'path' => '/mfa',
+        'target' => Controller\MfaAjaxController::class . '::handleRequest'
+    ],
+
     // Render flash messages
     'flashmessages_render' => [
         'path' => '/flashmessages/render',
diff --git a/typo3/sysext/backend/Configuration/Backend/Routes.php b/typo3/sysext/backend/Configuration/Backend/Routes.php
index f4ee172ed72ef1247e0c1e36cd6f29f528cef7ed..fef158bda195d91a2a87d19d15c74d8874e61d8f 100644
--- a/typo3/sysext/backend/Configuration/Backend/Routes.php
+++ b/typo3/sysext/backend/Configuration/Backend/Routes.php
@@ -64,6 +64,18 @@ return [
         'target' => Controller\LoginController::class . '::refreshAction'
     ],
 
+    // Authentication endpoint for Multi-factor authentication
+    'auth_mfa' => [
+        'path' => '/auth/mfa',
+        'target' => Controller\MfaController::class . '::handleRequest'
+    ],
+
+    // Multi-factor authentication configuration
+    'mfa' => [
+        'path' => '/mfa',
+        'target' => Controller\MfaConfigurationController::class . '::handleRequest'
+    ],
+
     /** Wizards */
     // Register table wizard
     'wizard_table' => [
diff --git a/typo3/sysext/backend/Configuration/Services.yaml b/typo3/sysext/backend/Configuration/Services.yaml
index 66711bbca1f9d751207b7f8f6ce7b73fe4d5f6f5..e62462705e1fcebf3f1130de90cc514d6f8e843c 100644
--- a/typo3/sysext/backend/Configuration/Services.yaml
+++ b/typo3/sysext/backend/Configuration/Services.yaml
@@ -53,6 +53,15 @@ services:
   TYPO3\CMS\Backend\Controller\HelpController:
     tags: ['backend.controller']
 
+  TYPO3\CMS\Backend\Controller\MfaConfigurationController:
+    tags: ['backend.controller']
+
+  TYPO3\CMS\Backend\Controller\MfaController:
+    tags: ['backend.controller']
+
+  TYPO3\CMS\Backend\Controller\MfaAjaxController:
+    tags: ['backend.controller']
+
   TYPO3\CMS\Backend\Form\FormDataProvider\SiteDatabaseEditRow:
     public: true
 
diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_mfa.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_mfa.xlf
new file mode 100644
index 0000000000000000000000000000000000000000..489d3862d10a4d5dd916ee793f880753b019c0f4
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Language/locallang_mfa.xlf
@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+	<file t3:id="1611662325" source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_mfa.xlf" date="2021-01-26T11:58:45Z" product-name="cms">
+		<header/>
+		<body>
+			<trans-unit id="overview.title" resname="overview.title">
+				<source>Multi-factor Authentication Overview</source>
+			</trans-unit>
+			<trans-unit id="overview.badge.active" resname="overview.badge.active">
+				<source>Active</source>
+			</trans-unit>
+			<trans-unit id="overview.badge.locked" resname="overview.badge.locked">
+				<source>Locked</source>
+			</trans-unit>
+			<trans-unit id="overview.defaultProvider" resname="overview.defaultProvider">
+				<source>Default provider</source>
+			</trans-unit>
+			<trans-unit id="overview.unlockLinkTitle" resname="overview.unlockLinkTitle">
+				<source>Unlock %s</source>
+			</trans-unit>
+			<trans-unit id="overview.unlockLinkLabel" resname="overview.unlockLinkLabel">
+				<source>Unlock</source>
+			</trans-unit>
+			<trans-unit id="overview.editLinkTitle" resname="overview.editLinkTitle">
+				<source>Edit %s</source>
+			</trans-unit>
+			<trans-unit id="overview.editLinkLabel" resname="overview.editLinkLabel">
+				<source>Edit / Change</source>
+			</trans-unit>
+			<trans-unit id="overview.deactivateLinkTitle" resname="overview.deactivateLinkTitle">
+				<source>Deactivate %s</source>
+			</trans-unit>
+			<trans-unit id="overview.deactivateLinkLabel" resname="overview.deactivateLinkLabel">
+				<source>Deactivate</source>
+			</trans-unit>
+			<trans-unit id="overview.setupLinkTitle" resname="overview.setupLinkTitle">
+				<source>Setup %s</source>
+			</trans-unit>
+			<trans-unit id="overview.setupLinkLabel" resname="overview.setupLinkLabel">
+				<source>Setup</source>
+			</trans-unit>
+			<trans-unit id="overview.setupRequired.title" resname="overview.setupRequired.title">
+				<source>Multi-factor authentication required</source>
+			</trans-unit>
+			<trans-unit id="overview.setupRequired.message" resname="overview.setupRequired.message">
+				<source>
+					Your installation requires MFA. Therefore please setup one of the following providers to be
+					able to continue to use the backend.
+				</source>
+			</trans-unit>
+			<trans-unit id="overview.noProviders.title" resname="overview.noProviders.title">
+				<source>No multi-factor authentication providers</source>
+			</trans-unit>
+			<trans-unit id="overview.noProviders.message" resname="overview.noProviders.message">
+				<source>There are currently no multi-factor authentication providers available.</source>
+			</trans-unit>
+			<trans-unit id="overview.noProviders.errorMessage" resname="overview.noProviders.errorMessage">
+				<source>
+					You are required to setup MFA but there are no providers to activate available. You should contact
+					your administrator to solve this.
+				</source>
+			</trans-unit>
+			<trans-unit id="providerNotFound" resname="providerNotFound">
+				<source>Selected MFA provider was not found!</source>
+			</trans-unit>
+			<trans-unit id="setup.instructions" resname="setup.instructions">
+				<source>Setup instructions</source>
+			</trans-unit>
+			<trans-unit id="setup.instructions.close" resname="setup.instructions.close">
+				<source>Close setup instructions</source>
+			</trans-unit>
+			<trans-unit id="setup.title" resname="setup.title">
+				<source>Set up %s</source>
+			</trans-unit>
+			<trans-unit id="edit.title" resname="edit.title">
+				<source>Edit %s</source>
+			</trans-unit>
+			<trans-unit id="edit.defaultProvider" resname="edit.defaultProvider">
+				<source>Default provider</source>
+			</trans-unit>
+			<trans-unit id="edit.defaultProvider.description" resname="edit.defaultProvider.description">
+				<source>Select this as your default provider for login into the TYPO3 backend.</source>
+			</trans-unit>
+			<trans-unit id="edit.defaultProvider.inputLabel" resname="edit.defaultProvider.inputLabel">
+				<source>Use as default provider</source>
+			</trans-unit>
+			<trans-unit id="edit.deactivateProvider" resname="edit.deactivateProvider">
+				<source>Deactivate provider</source>
+			</trans-unit>
+			<trans-unit id="edit.deactivateProvider.description" resname="edit.deactivateProvider.description">
+				<source>If you don't want to use this provider anymore, you can deactivate it here.</source>
+			</trans-unit>
+			<trans-unit id="edit.deactivateProvider.linkTitle" resname="edit.deactivateProvider.linkTitle">
+				<source>Deactivate %s</source>
+			</trans-unit>
+			<trans-unit id="edit.deactivateProvider.linkText" resname="edit.deactivateProvider.linkText">
+				<source>Deactivate this provider</source>
+			</trans-unit>
+			<trans-unit id="activate.failure" resname="activate.failure">
+				<source>Could not activate MFA provider %s. Please try again.</source>
+			</trans-unit>
+			<trans-unit id="activate.success" resname="activate.success">
+				<source>Successfully activated MFA provider %s.</source>
+			</trans-unit>
+			<trans-unit id="deactivate.failure" resname="deactivate.failure">
+				<source>Could not deactivate MFA provider %s. Please try again.</source>
+			</trans-unit>
+			<trans-unit id="deactivate.success" resname="deactivate.success">
+				<source>Successfully deactivated MFA provider %s.</source>
+			</trans-unit>
+			<trans-unit id="unlock.failure" resname="unlock.failure">
+				<source>Could not unlock MFA provider %s. Please try again.</source>
+			</trans-unit>
+			<trans-unit id="unlock.success" resname="unlock.success">
+				<source>Successfully unlocked MFA provider %s.</source>
+			</trans-unit>
+			<trans-unit id="save.failure" resname="save.failure">
+				<source>Could not update MFA provider %s. Please try again.</source>
+			</trans-unit>
+			<trans-unit id="save.success" resname="save.success">
+				<source>Successfully updated MFA provider %s.</source>
+			</trans-unit>
+			<trans-unit id="auth.authError" resname="auth.authError">
+				<source>Authentication was not succesful</source>
+			</trans-unit>
+			<trans-unit id="auth.submit" resname="auth.submit">
+				<source>Verify</source>
+			</trans-unit>
+			<trans-unit id="auth.cancel" resname="auth.cancel">
+				<source>Go back</source>
+			</trans-unit>
+			<trans-unit id="auth.locked" resname="auth.locked">
+				<source>This provider is temporarily locked!</source>
+			</trans-unit>
+			<trans-unit id="auth.alternativeProviders" resname="auth.alternativeProviders">
+				<source>Alternative providers</source>
+			</trans-unit>
+			<trans-unit id="auth.alternativeProviders.use" resname="auth.alternativeProviders.use">
+				<source>Use %s</source>
+			</trans-unit>
+			<trans-unit id="ajax.success">
+				<source>Success</source>
+			</trans-unit>
+			<trans-unit id="ajax.error">
+				<source>An error occurred</source>
+			</trans-unit>
+			<trans-unit id="ajax.invalidRequest">
+				<source>Invalid request could not be processed</source>
+			</trans-unit>
+			<trans-unit id="ajax.insufficientPermissions">
+				<source>Your are not allowed to perfom this action</source>
+			</trans-unit>
+			<trans-unit id="ajax.deactivate.providersNotDeactivated">
+				<source>No provider was deactivated</source>
+			</trans-unit>
+			<trans-unit id="ajax.deactivate.providersDeactivated">
+				<source>Successfully deactivated all active providers for user %s</source>
+			</trans-unit>
+			<trans-unit id="ajax.deactivate.providerNotFound">
+				<source>Provider %s could not be found</source>
+			</trans-unit>
+			<trans-unit id="ajax.deactivate.providerNotDeactivated">
+				<source>Could not deactivate provider %s</source>
+			</trans-unit>
+			<trans-unit id="ajax.deactivate.providerDeactivated">
+				<source>Successfully deactivated provider %s for user %s</source>
+			</trans-unit>
+		</body>
+	</file>
+</xliff>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Auth.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Auth.html
new file mode 100644
index 0000000000000000000000000000000000000000..3ea9301ba2dfd7e50aca68fd03d946dc7673d5e8
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Auth.html
@@ -0,0 +1,69 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
+
+<div class="typo3-login">
+    <div class="typo3-login-inner">
+        <div class="typo3-login-container">
+            <div class="typo3-login-wrap">
+                <div class="card card-login">
+                    <header class="card-heading">
+                        <h2 class="text-center">{provider.title -> f:translate(id: provider.title)}</h2>
+                    </header>
+                    <main class="card-body">
+                        <f:if condition="{isLocked} || {hasAuthError}">
+                            <div id="t3-login-error">
+                                <div class="alert alert-danger">
+                                    <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.{f:if(condition: isLocked, then: 'locked', else: 'authError')}"/>
+                                </div>
+                            </div>
+                        </f:if>
+                        <f:if condition="!{isLocked}">
+                            <f:then>
+                                <form
+                                    action="{f:be.uri(route:'auth_mfa', parameters:'{action: \'verify\'}')}"
+                                    method="post"
+                                    enctype="multipart/form-data"
+                                    name="verify"
+                                    id="mfaController">
+                                    <input type="hidden" name="identifier" value="{provider.identifier}" />
+                                    {providerContent -> f:format.raw()}
+                                    <div class="form-group" id="t3-login-submit-section">
+                                        <button class="btn btn-block btn-login" type="submit" name="submit" autocomplete="off">
+                                            <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.submit"/>
+                                        </button>
+                                    </div>
+                                </form>
+                            </f:then>
+                            <f:else>
+                                <f:comment>In case, the provider is temporarily locked, still allow to render provider specific content.</f:comment>
+                                {providerContent -> f:format.raw()}
+                            </f:else>
+                        </f:if>
+                        <div class="cancel-authentication">
+                            <div class="text-right">
+                                <f:be.link route="auth_mfa" parameters="{action: 'cancel'}" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.cancel')}">
+                                    <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.cancel"/>
+                                </f:be.link>
+                            </div>
+                        </div>
+                    </main>
+                    <f:if condition="{alternativeProviders}">
+                        <footer class="card-footer">
+                            <h5><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.alternativeProviders"/></h5>
+                            <ul class="list-group px-lg-4">
+                                <f:for each="{alternativeProviders}" as="alternativeProvider">
+                                    <li>
+                                        <f:be.link route="auth_mfa" parameters="{identifier: alternativeProvider.identifier}" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.alternativeProviders.use', arguments: {0: '{alternativeProvider.title  -> f:translate(id: alternativeProvider.title)}'})}">
+                                            <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:auth.alternativeProviders.use" arguments="{0: '{alternativeProvider.title  -> f:translate(id: alternativeProvider.title)}'}"/>
+                                        </f:be.link>
+                                    </li>
+                                </f:for>
+                            </ul>
+                        </footer>
+                    </f:if>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+</html>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Edit.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..74916e77c400fbeea9883541324f36ff2c08e47d
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Edit.html
@@ -0,0 +1,48 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
+
+<f:variable name="providerTitle" value="{provider.title -> f:translate(id: provider.title)}"/>
+
+<h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.title" arguments="{0: providerTitle}"/></h1>
+<p><f:format.nl2br>{provider.description -> f:translate(id: provider.description)}</f:format.nl2br></p>
+
+<form
+    action="{f:be.uri(route:'mfa', parameters:'{action: \'save\'}')}"
+    method="post"
+    enctype="multipart/form-data"
+    name="edit"
+    id="mfaConfigurationController">
+    <input type="hidden" name="identifier" value="{provider.identifier}" />
+    <div class="mb-3">
+        {providerContent -> f:format.raw()}
+    </div>
+    <div class="mfa-provider-settings">
+        <f:if condition="{provider.defaultProviderAllowed}">
+            <div class="row mb-3">
+                <div class="col-md-5">
+                    <h4><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.defaultProvider"/></h4>
+                    <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.defaultProvider.description"/></p>
+                    <div class="form-check">
+                        <input type="checkbox" name="defaultProvider" id="defaultProvider" class="form-check-input" value="1" {f:if(condition: isDefaultProvider, then: 'checked="checked"')} />
+                        <label class="form-check-label" for="defaultProvider">
+                    <span class="form-check-label-text">
+                        <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.defaultProvider.inputLabel"/>
+                    </span>
+                        </label>
+                    </div>
+                </div>
+            </div>
+        </f:if>
+        <div class="row">
+            <div class="col">
+                <h4><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider"/></h4>
+                <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider.description"/></p>
+                <f:be.link route="mfa" parameters="{action: 'deactivate', identifier: provider.identifier}" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider.linkTitle', arguments: '{0: providerTitle}')}" class="btn btn-danger">
+                    <core:icon identifier="actions-toggle-off" />
+                    <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:edit.deactivateProvider.linkText"/>
+                </f:be.link>
+            </div>
+        </div>
+    </div>
+</form>
+
+</html>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Overview.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Overview.html
new file mode 100644
index 0000000000000000000000000000000000000000..18064d1d9be2554cb92f1be330f75c6dfeb12b0a
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Overview.html
@@ -0,0 +1,100 @@
+<html
+    xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
+    xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers"
+    xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
+    data-namespace-typo3-fluid="true">
+
+<h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.title" /></h1>
+
+<f:if condition="{setupRequired}">
+    <f:then>
+        <f:if condition="{providers}">
+            <f:then>
+                <f:be.infobox state="1" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupRequired.title')}">
+                    <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupRequired.message"/></p>
+                </f:be.infobox>
+            </f:then>
+            <f:else>
+                <f:be.infobox state="2" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.title')}">
+                    <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.errorMessage"/></p>
+                </f:be.infobox>
+            </f:else>
+        </f:if>
+    </f:then>
+    <f:else if="!{providers}">
+        <f:be.infobox state="-1" title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.title')}">
+            <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.noProviders.message"/></p>
+        </f:be.infobox>
+    </f:else>
+</f:if>
+
+<f:if condition="{providers}">
+    <f:then>
+        <div class="card-container">
+            <f:for each="{providers}" as="provider">
+                <f:render section="Item" arguments="{provider: provider, defaultProvider: defaultProvider, recommendedProvider: recommendedProvider}"/>
+            </f:for>
+        </div>
+    </f:then>
+</f:if>
+
+<f:section name="Item">
+    <f:variable name="providerTitle" value="{provider.title -> f:translate(id: provider.title)}"/>
+    <div class="card card-size-fixed-small {f:if(condition: '{recommendedProvider} == {provider.identifier}', then: 'border-success shadow')}" id="{provider.identifier}-provider">
+        <div class="card-header">
+            <div class="card-icon">
+                <core:icon identifier="{provider.iconIdentifier}" size="large"/>
+            </div>
+            <div class="card-header-body">
+                <h1 class="card-title">
+                    {providerTitle}
+                    <be:mfa.ifHasState state="active" provider="{provider}">
+                        <span class="badge badge-{be:mfa.ifHasState(state: 'locked', provider: provider, then: 'danger', else: 'success')}">
+                            <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.badge.{be:mfa.ifHasState(state: 'locked', provider: provider, then: 'locked', else: 'active')}"/>
+                        </span>
+                    </be:mfa.ifHasState>
+                    <f:if condition="{defaultProvider} == {provider.identifier}">
+                        <span title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.defaultProvider')}">
+                            <core:icon identifier="actions-star" />
+                        </span>
+                    </f:if>
+                </h1>
+            </div>
+        </div>
+        <div class="card-content">
+            <p class="card-text">{provider.description -> f:translate(id: provider.description)}</p>
+        </div>
+        <div class="card-footer">
+            <be:mfa.ifHasState state="active" provider="{provider}">
+                <f:then>
+                    <be:mfa.ifHasState state="locked" provider="{provider}">
+                        <f:then>
+                            <f:be.link route="mfa" parameters="{action: 'unlock', identifier: provider.identifier}" class="btn btn-success" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.unlockLinkTitle', arguments: {0: providerTitle})}">
+                                <core:icon identifier="actions-unlock" />
+                                <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.unlockLinkLabel"/>
+                            </f:be.link>
+                        </f:then>
+                        <f:else>
+                            <f:be.link route="mfa" parameters="{action: 'edit', identifier: provider.identifier}" class="btn btn-default" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.editLinkTitle', arguments: {0: providerTitle})}">
+                                <core:icon identifier="actions-open" />
+                                <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.editLinkLabel"/>
+                            </f:be.link>
+                        </f:else>
+                    </be:mfa.ifHasState>
+                    <f:be.link route="mfa" parameters="{action: 'deactivate', identifier: provider.identifier}" class="btn btn-danger" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.deactivateLinkTitle', arguments: {0: providerTitle})}">
+                        <core:icon identifier="actions-toggle-off" />
+                        <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.deactivateLinkLabel"/>
+                    </f:be.link>
+                </f:then>
+                <f:else>
+                    <f:be.link route="mfa" parameters="{action: 'setup', identifier: provider.identifier}" class="btn btn-{f:if(condition: '{recommendedProvider} == {provider.identifier}', then: 'success', else: 'default')}" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupLinkTitle', arguments: {0: providerTitle})}">
+                        <core:icon identifier="actions-add" />
+                        <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:overview.setupLinkLabel"/>
+                    </f:be.link>
+                </f:else>
+            </be:mfa.ifHasState>
+        </div>
+    </div>
+</f:section>
+
+</html>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Mfa/Setup.html b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Setup.html
new file mode 100644
index 0000000000000000000000000000000000000000..400325af708ead73a4066433a4c73217eb3741a2
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Templates/Mfa/Setup.html
@@ -0,0 +1,30 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
+
+<h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:setup.title" arguments="{0: '{provider.title -> f:translate(id: provider.title)}'}"/></h1>
+
+<div class="row g-6">
+    <div class="col">
+        <form
+            action="{f:be.uri(route:'mfa', parameters:'{action: \'activate\'}')}"
+            method="post"
+            enctype="multipart/form-data"
+            name="activate"
+            id="mfaConfigurationController">
+            <input type="hidden" name="identifier" value="{provider.identifier}" />
+            {providerContent -> f:format.raw()}
+        </form>
+    </div>
+    <f:if condition="{provider.setupInstructions}">
+        <div class="alert alert-info alert-dismissible col-md-4">
+            <div class="alert-heading">
+                <h4><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:setup.instructions"/></h4>
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:setup.instructions.close')}"></button>
+            </div>
+            <div class="alert-body">
+                <f:format.nl2br>{provider.setupInstructions -> f:translate(id: provider.setupInstructions)}</f:format.nl2br>
+            </div>
+        </div>
+    </f:if>
+</div>
+
+</html>
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/MfaInfoElement.js b/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/MfaInfoElement.js
new file mode 100644
index 0000000000000000000000000000000000000000..b4b082631bcf37c8ea030a36a1566e19a5903ad7
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/MfaInfoElement.js
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+define(["require","exports","TYPO3/CMS/Core/Ajax/AjaxRequest","TYPO3/CMS/Core/DocumentService","TYPO3/CMS/Core/Event/RegularEvent","TYPO3/CMS/Backend/Notification","TYPO3/CMS/Backend/Modal","TYPO3/CMS/Backend/Enum/Severity"],(function(t,e,i,a,s,r,n,l){"use strict";var o;!function(t){t.deactivteProviderButton=".t3js-deactivate-provider-button",t.deactivteMfaButton=".t3js-deactivate-mfa-button",t.providerslist=".t3js-mfa-active-providers-list",t.mfaStatusLabel=".t3js-mfa-status-label"}(o||(o={}));return class{constructor(t,e){this.options=null,this.fullElement=null,this.deactivteProviderButtons=null,this.deactivteMfaButton=null,this.providersList=null,this.mfaStatusLabel=null,this.request=null,this.options=e,a.ready().then(e=>{this.fullElement=e.querySelector(t),this.deactivteProviderButtons=this.fullElement.querySelectorAll(o.deactivteProviderButton),this.deactivteMfaButton=this.fullElement.querySelector(o.deactivteMfaButton),this.providersList=this.fullElement.querySelector(o.providerslist),this.mfaStatusLabel=this.fullElement.parentElement.querySelector(o.mfaStatusLabel),this.registerEvents()})}registerEvents(){new s("click",t=>{t.preventDefault(),this.prepareDeactivateRequest(this.deactivteMfaButton)}).bindTo(this.deactivteMfaButton),this.deactivteProviderButtons.forEach(t=>{new s("click",e=>{e.preventDefault(),this.prepareDeactivateRequest(t)}).bindTo(t)})}prepareDeactivateRequest(t){const e=n.show(t.dataset.confirmationTitle||t.getAttribute("title")||"Deactivate provider(s)",t.dataset.confirmationContent||"Are you sure you want to continue? This action cannot be undone and will be applied immediately!",l.SeverityEnum.warning,[{text:t.dataset.confirmationCancelText||"Cancel",active:!0,btnClass:"btn-default",name:"cancel"},{text:t.dataset.confirmationDeactivateText||"Deactivate",btnClass:"btn-warning",name:"deactivate",trigger:()=>{this.sendDeactivateRequest(t.dataset.provider)}}]);e.on("button.clicked",()=>{e.modal("hide")})}sendDeactivateRequest(t){this.request instanceof i&&this.request.abort(),this.request=new i(TYPO3.settings.ajaxUrls.mfa),this.request.post({action:"deactivate",provider:t,userId:this.options.userId,tableName:this.options.tableName}).then(async e=>{const i=await e.resolve();if(i.status.length>0&&i.status.forEach(t=>{i.success?r.success(t.title,t.message):r.error(t.title,t.message)}),!i.success)return;if(void 0===t||0===i.remaining)return void this.deactivateMfa();if(null===this.providersList)return;const a=this.providersList.querySelector("li#provider-"+t);if(null===a)return;a.remove();0===this.providersList.querySelectorAll("li").length&&this.deactivateMfa()}).finally(()=>{this.request=null})}deactivateMfa(){this.deactivteMfaButton.classList.add("disabled"),this.deactivteMfaButton.setAttribute("disabled","disabled"),null!==this.providersList&&this.providersList.remove(),null!==this.mfaStatusLabel&&(this.mfaStatusLabel.innerText=this.mfaStatusLabel.dataset.alternativeLabel,this.mfaStatusLabel.classList.remove("label-success"),this.mfaStatusLabel.classList.add("label-danger"))}}}));
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js b/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js
index a22c3113d2d5f6ba6bf4e04ee3567cabe07eaba8..15efb8e47002411c66cc88c380e79861e9d96123 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/Viewport.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-define(["require","exports","./Viewport/ContentContainer","./Enum/Viewport/ScaffoldIdentifier","./Event/ConsumerScope","./Viewport/Loader","./Viewport/NavigationContainer","./Storage/Persistent","./Viewport/Topbar"],(function(t,e,i,n,o,a,s,r,h){"use strict";class d{constructor(){this.Loader=a,this.NavigationContainer=null,this.ContentContainer=null,this.consumerScope=o,this.fallbackNavigationSizeIfNeeded=t=>{let e=t.currentTarget;0!==this.NavigationContainer.getWidth()&&e.outerWidth<this.NavigationContainer.getWidth()+this.NavigationContainer.getPosition().left+300&&this.NavigationContainer.autoWidth()},this.handleMouseMove=t=>{this.resizeNavigation(t.clientX)},this.handleTouchMove=t=>{this.resizeNavigation(t.changedTouches[0].clientX)},this.resizeNavigation=t=>{let e=Math.round(t)-Math.round(this.NavigationContainer.getPosition().left);this.NavigationContainer.setWidth(e)},this.stopResizeNavigation=()=>{this.navigationDragHandler.classList.remove("resizing"),this.document.removeEventListener("mousemove",this.handleMouseMove,!1),this.document.removeEventListener("mouseup",this.stopResizeNavigation,!1),this.document.removeEventListener("touchmove",this.handleTouchMove,!1),this.document.removeEventListener("touchend",this.stopResizeNavigation,!1),r.set("navigation.width",this.NavigationContainer.getWidth())},this.startResizeNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.navigationDragHandler.classList.add("resizing"),this.document.addEventListener("mousemove",this.handleMouseMove,!1),this.document.addEventListener("mouseup",this.stopResizeNavigation,!1),this.document.addEventListener("touchmove",this.handleTouchMove,!1),this.document.addEventListener("touchend",this.stopResizeNavigation,!1))},this.toggleNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.NavigationContainer.toggle())},this.document=document,this.navigationDragHandler=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationDrag);let t=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationSwitcher);this.Topbar=new h,this.NavigationContainer=new s(this.consumerScope,t),this.ContentContainer=new i(this.consumerScope),this.NavigationContainer.setWidth(r.get("navigation.width")),window.addEventListener("resize",this.fallbackNavigationSizeIfNeeded,{passive:!0}),t&&(t.addEventListener("mouseup",this.toggleNavigation,{passive:!0}),t.addEventListener("touchstart",this.toggleNavigation,{passive:!0})),this.navigationDragHandler&&(this.navigationDragHandler.addEventListener("mousedown",this.startResizeNavigation,{passive:!0}),this.navigationDragHandler.addEventListener("touchstart",this.startResizeNavigation,{passive:!0}))}}let v;return top.TYPO3.Backend?v=top.TYPO3.Backend:(v=new d,top.TYPO3.Backend=v),v}));
\ No newline at end of file
+define(["require","exports","./Viewport/ContentContainer","./Enum/Viewport/ScaffoldIdentifier","./Event/ConsumerScope","./Viewport/Loader","./Viewport/NavigationContainer","./Storage/Persistent","./Viewport/Topbar"],(function(t,e,i,n,o,a,s,r,h){"use strict";class d{constructor(){this.Loader=a,this.NavigationContainer=null,this.ContentContainer=null,this.consumerScope=o,this.fallbackNavigationSizeIfNeeded=t=>{let e=t.currentTarget;0!==this.NavigationContainer.getWidth()&&e.outerWidth<this.NavigationContainer.getWidth()+this.NavigationContainer.getPosition().left+300&&this.NavigationContainer.autoWidth()},this.handleMouseMove=t=>{this.resizeNavigation(t.clientX)},this.handleTouchMove=t=>{this.resizeNavigation(t.changedTouches[0].clientX)},this.resizeNavigation=t=>{let e=Math.round(t)-Math.round(this.NavigationContainer.getPosition().left);this.NavigationContainer.setWidth(e)},this.stopResizeNavigation=()=>{this.navigationDragHandler.classList.remove("resizing"),this.document.removeEventListener("mousemove",this.handleMouseMove,!1),this.document.removeEventListener("mouseup",this.stopResizeNavigation,!1),this.document.removeEventListener("touchmove",this.handleTouchMove,!1),this.document.removeEventListener("touchend",this.stopResizeNavigation,!1),r.set("navigation.width",this.NavigationContainer.getWidth())},this.startResizeNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.navigationDragHandler.classList.add("resizing"),this.document.addEventListener("mousemove",this.handleMouseMove,!1),this.document.addEventListener("mouseup",this.stopResizeNavigation,!1),this.document.addEventListener("touchmove",this.handleTouchMove,!1),this.document.addEventListener("touchend",this.stopResizeNavigation,!1))},this.toggleNavigation=t=>{t instanceof MouseEvent&&2===t.button||(t.stopPropagation(),this.NavigationContainer.toggle())},this.document=document,this.navigationDragHandler=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationDrag);let t=document.querySelector(n.ScaffoldIdentifierEnum.contentNavigationSwitcher);this.Topbar=new h,this.NavigationContainer=new s(this.consumerScope,t),this.ContentContainer=new i(this.consumerScope),document.querySelector(n.ScaffoldIdentifierEnum.contentNavigation)&&this.NavigationContainer.setWidth(r.get("navigation.width")),window.addEventListener("resize",this.fallbackNavigationSizeIfNeeded,{passive:!0}),t&&(t.addEventListener("mouseup",this.toggleNavigation,{passive:!0}),t.addEventListener("touchstart",this.toggleNavigation,{passive:!0})),this.navigationDragHandler&&(this.navigationDragHandler.addEventListener("mousedown",this.startResizeNavigation,{passive:!0}),this.navigationDragHandler.addEventListener("touchstart",this.startResizeNavigation,{passive:!0}))}}let v;return top.TYPO3.Backend?v=top.TYPO3.Backend:(v=new d,top.TYPO3.Backend=v),v}));
\ No newline at end of file
diff --git a/typo3/sysext/beuser/Classes/ViewHelpers/MfaStatusViewHelper.php b/typo3/sysext/beuser/Classes/ViewHelpers/MfaStatusViewHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..2a0af6a5d3aabd64b7b8e882e8a371c5a71533a3
--- /dev/null
+++ b/typo3/sysext/beuser/Classes/ViewHelpers/MfaStatusViewHelper.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Beuser\ViewHelpers;
+
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper;
+
+/**
+ * Render MFA status information
+ *
+ * @internal
+ */
+class MfaStatusViewHelper extends AbstractTagBasedViewHelper
+{
+    protected $tagName = 'span';
+
+    public function initializeArguments(): void
+    {
+        parent::initializeArguments();
+        $this->registerUniversalTagAttributes();
+        $this->registerArgument('userUid', 'int', 'The uid of the user to check', true);
+    }
+
+    public function render(): string
+    {
+        $userUid = (int)($this->arguments['userUid'] ?? 0);
+        if (!$userUid) {
+            return '';
+        }
+
+        $backendUser = GeneralUtility::makeInstance(BackendUserAuthentication::class);
+        $backendUser->enablecolumns = ['deleted' => true];
+        $backendUser->setBeUserByUid($userUid);
+
+        $mfaProviderRegistry = GeneralUtility::makeInstance(MfaProviderRegistry::class);
+
+        // Check if user has active providers
+        if (!$mfaProviderRegistry->hasActiveProviders($backendUser)) {
+            return '';
+        }
+
+        // Check locked providers
+        if ($mfaProviderRegistry->hasLockedProviders($backendUser)) {
+            $this->tag->addAttribute('class', 'label label-warning');
+            $this->tag->setContent(htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:lockedMfaProviders')));
+            return $this->tag->render();
+        }
+
+        // Add mfa enabled label since we have active providers and non of them are locked
+        $this->tag->addAttribute('class', 'label label-info');
+        $this->tag->setContent(htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:mfaEnabled')));
+        return $this->tag->render();
+    }
+
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+}
diff --git a/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf b/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf
index 5ae57abd9298d946f975436a25b5d367939b565a..046fa74f5a849a4c8052b7a7327fa7b302766868 100644
--- a/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf
+++ b/typo3/sysext/beuser/Resources/Private/Language/locallang.xlf
@@ -180,6 +180,12 @@
 			<trans-unit id="online" resname="online">
 				<source>online</source>
 			</trans-unit>
+			<trans-unit id="lockedMfaProviders" resname="lockedMfaProviders">
+				<source>Locked MFA providers</source>
+			</trans-unit>
+			<trans-unit id="mfaEnabled" resname="mfaEnabled">
+				<source>MFA enabled</source>
+			</trans-unit>
 			<trans-unit id="visibility.hide" resname="visibility.hide">
 				<source>Hide</source>
 			</trans-unit>
diff --git a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html
index 0b5a2e8219595f3a3f1906fdd355c336c391d09c..9ac59bf0eda82d691fef13eebd354db906902a9e 100644
--- a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html
+++ b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html
@@ -17,6 +17,7 @@
         <f:if condition="{onlineBackendUsers.{backendUser.uid}}">
             <span class="label label-success"><f:translate key="online" /></span>
         </f:if>
+        <bu:mfaStatus userUid="{backendUser.uid}"/>
         <br />
         <f:if condition="{backendUser.realName}">
             <be:link.editRecord table="be_users" uid="{backendUser.uid}" title="edit">
diff --git a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html
index 01d52640985764aa0fb98c466d0d7c9dcff3e11c..11e7c7ee69bbcc7e0eb2a4133d706c87fc0f67e0 100644
--- a/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html
+++ b/typo3/sysext/beuser/Resources/Private/Partials/BackendUser/OnlineListRow.html
@@ -11,7 +11,8 @@
                 </td>
                 <td class="col-title">
                     <b>{onlineUser.backendUser.userName}</b>
-                    <span class="label label-success"><f:translate key="online" /></span><br />
+                    <span class="label label-success"><f:translate key="online" /></span>
+                    <bu:mfaStatus userUid="{onlineUser.backendUser.uid}"/><br />
                     {onlineUser.backendUser.realName}
                 </td>
             </f:then>
diff --git a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php
index bb6c779313d058a5abd4ef4f21ac40106edc0ab5..b15b39309af20b73c477990fe683f84c5d32275c 100644
--- a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php
+++ b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php
@@ -19,6 +19,8 @@ use Psr\Http\Message\ResponseInterface;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerAwareTrait;
 use Symfony\Component\HttpFoundation\Cookie;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Crypto\Random;
 use TYPO3\CMS\Core\Database\Connection;
@@ -262,7 +264,15 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
 
         // Load user session, check to see if anyone has submitted login-information and if so authenticate
         // the user with the session. $this->user[uid] may be used to write log...
-        $this->checkAuthentication();
+        try {
+            $this->checkAuthentication();
+        } catch (MfaRequiredException $mfaRequiredException) {
+            // Ensure the cookie is still set to keep the user session available
+            if (!$this->dontSetCookie || $this->isRefreshTimeBasedCookie()) {
+                $this->setSessionCookie();
+            }
+            throw $mfaRequiredException;
+        }
         // Set cookie if generally enabled or if the current session is a non-session cookie (FE permalogin)
         if (!$this->dontSetCookie || $this->isRefreshTimeBasedCookie()) {
             $this->setSessionCookie();
@@ -607,6 +617,9 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
                 $this->regenerateSessionId();
             }
 
+            // Check if multi-factor authentication is required
+            $this->evaluateMfaRequirements();
+
             if ($activeLogin) {
                 // User logged in - write that to the log!
                 if ($this->writeStdLog) {
@@ -636,6 +649,34 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
         }
     }
 
+    /**
+     * This method checks if the user is authenticated but has not succeeded in
+     * passing his MFA challenge. This method can therefore only be used if a user
+     * has been authenticated against his first authentication method (username+password
+     * or any other authentication token).
+     *
+     * @throws MfaRequiredException
+     * @internal
+     */
+    protected function evaluateMfaRequirements(): void
+    {
+        // MFA has been validated already, nothing to do
+        if ($this->getSessionData('mfa')) {
+            return;
+        }
+        // If the user session does not contain the 'mfa' key - indicating that MFA is already
+        // passed - get the first provider for authentication, which is either the default provider
+        // or the first active provider (based on the providers configured ordering).
+        $provider = GeneralUtility::makeInstance(MfaProviderRegistry::class)->getFirstAuthenticationAwareProvider($this);
+        // Throw an exception (hopefully called in a middleware) when an active provider for the user exists
+        if ($provider !== null) {
+            throw new MfaRequiredException($provider, 1613687097);
+        }
+        // @todo If user has no active providers, check if the user is required to
+        //       setup MFA and redirect to a standalone registration controller.
+        //       Currently we just let the user proceed to its original target.
+    }
+
     /**
      * Implement functionality when there was a failed login
      */
diff --git a/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php b/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php
index 9328def9ae41c2fab862269185457c4915ca7332..12823c24681209696e26c70e9b97ef31b021f9d6 100644
--- a/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php
+++ b/typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php
@@ -588,7 +588,7 @@ class BackendUserAuthentication extends AbstractUserAuthentication
      * If user is admin TRUE is also returned
      * Please see the document Inside TYPO3 for examples.
      *
-     * @param string $type The type value; "webmounts", "filemounts", "pagetypes_select", "tables_select", "tables_modify", "non_exclude_fields", "modules", "available_widgets"
+     * @param string $type The type value; "webmounts", "filemounts", "pagetypes_select", "tables_select", "tables_modify", "non_exclude_fields", "modules", "available_widgets", "mfa_providers"
      * @param string $value String to search for in the groupData-list
      * @return bool TRUE if permission is granted (that is, the value was found in the groupData list - or the BE_USER is "admin")
      */
@@ -1222,6 +1222,8 @@ class BackendUserAuthentication extends AbstractUserAuthentication
             $this->groupData['modules'] = $this->user['userMods'];
             // Add available widgets
             $this->groupData['available_widgets'] = $this->user['available_widgets'] ?? '';
+            // Add allowed mfa providers
+            $this->groupData['mfa_providers'] = $this->user['mfa_providers'] ?? '';
             // Add Allowed Languages
             $this->groupData['allowed_languages'] = $this->user['allowed_languages'];
             // Set user value for workspace permissions.
@@ -1252,6 +1254,7 @@ class BackendUserAuthentication extends AbstractUserAuthentication
                 // Gather permission detail fields
                 $this->groupData['modules'] .= ',' . $groupInfo['groupMods'];
                 $this->groupData['available_widgets'] .= ',' . $groupInfo['availableWidgets'];
+                $this->groupData['mfa_providers'] .= ',' . $groupInfo['mfa_providers'];
                 $this->groupData['tables_select'] .= ',' . $groupInfo['tables_select'];
                 $this->groupData['tables_modify'] .= ',' . $groupInfo['tables_modify'];
                 $this->groupData['pagetypes_select'] .= ',' . $groupInfo['pagetypes_select'];
@@ -1291,6 +1294,7 @@ class BackendUserAuthentication extends AbstractUserAuthentication
             $this->groupData['custom_options'] = StringUtility::uniqueList($this->groupData['custom_options'] ?? '');
             $this->groupData['modules'] = StringUtility::uniqueList($this->groupData['modules'] ?? '');
             $this->groupData['available_widgets'] = StringUtility::uniqueList($this->groupData['available_widgets'] ?? '');
+            $this->groupData['mfa_providers'] = StringUtility::uniqueList($this->groupData['mfa_providers'] ?? '');
             $this->groupData['file_permissions'] = StringUtility::uniqueList($this->groupData['file_permissions'] ?? '');
 
             // Check if the user access to all web mounts set
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderInterface.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..4bf5ca4bd8f8819154020ad00c9c0bec2f0a2abe
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderInterface.php
@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * To be implemented by all MFA providers.
+ *
+ * @internal This is an experimental TYPO3 Core API and subject to change until v11 LTS
+ */
+interface MfaProviderInterface
+{
+    /**
+     * Check if the current request can be handled by this provider (e.g.
+     * necessary query arguments are set).
+     *
+     * @param ServerRequestInterface $request
+     * @return bool
+     */
+    public function canProcess(ServerRequestInterface $request): bool;
+
+    /**
+     * Check if provider is active for the user by e.g. checking the user
+     * record for some provider specific active state.
+     *
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function isActive(MfaProviderPropertyManager $propertyManager): bool;
+
+    /**
+     * Check if provider is temporarily locked for the user, because
+     * of e.g. to much false authentication attempts. This differs
+     * from the "isActive" state on purpose, so please DO NOT use
+     * the "isActive" state for such check internally. This will
+     * allow attackers to easily circumvent MFA!
+     *
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function isLocked(MfaProviderPropertyManager $propertyManager): bool;
+
+    /**
+     * Verifies the MFA request
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
+
+    /**
+     * Generate the provider specific response for the given view type.
+     * Note: Currently the calling controller only evaluates the response
+     * body and directly injects it into the corresponding view. It's however
+     * planned to also take further information like headers into account.
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @param string $type
+     * @return ResponseInterface
+     * @see MfaViewType
+     */
+    public function handleRequest(
+        ServerRequestInterface $request,
+        MfaProviderPropertyManager $propertyManager,
+        string $type
+    ): ResponseInterface;
+
+    /**
+     * Activate / register this provider for the user
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool TRUE in case operation was successful, FALSE otherwise
+     */
+    public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
+
+    /**
+     * Deactivate this provider for the user
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool TRUE in case operation was successful, FALSE otherwise
+     */
+    public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
+
+    /**
+     * Unlock this provider for the user
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool TRUE in case operation was successful, FALSE otherwise
+     */
+    public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
+
+    /**
+     * Handle changes of the provider by the user
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool TRUE in case operation was successful, FALSE otherwise
+     */
+    public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool;
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifest.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d4f14644c4b99f6b3a43bd632048ba0025a0861c
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifest.php
@@ -0,0 +1,149 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa;
+
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Adapter for MFA providers
+ *
+ * @internal
+ */
+final class MfaProviderManifest implements MfaProviderManifestInterface
+{
+    private string $identifier;
+    private string $title;
+    private string $description;
+    private string $setupInstructions;
+    private string $iconIdentifier;
+    private bool $isDefaultProviderAllowed;
+    private string $serviceName;
+    private ContainerInterface $container;
+    private ?MfaProviderInterface $instance = null;
+
+    public function __construct(
+        string $identifier,
+        string $title,
+        string $description,
+        string $setupInstructions,
+        string $iconIdentifier,
+        bool $isDefaultProviderAllowed,
+        string $serviceName,
+        ContainerInterface $container
+    ) {
+        $this->identifier = $identifier;
+        $this->title = $title;
+        $this->description = $description;
+        $this->setupInstructions = $setupInstructions;
+        $this->iconIdentifier = $iconIdentifier;
+        $this->isDefaultProviderAllowed = $isDefaultProviderAllowed;
+        $this->serviceName = $serviceName;
+        $this->container = $container;
+    }
+
+    public function getIdentifier(): string
+    {
+        return $this->identifier;
+    }
+
+    public function getTitle(): string
+    {
+        return $this->title;
+    }
+
+    public function getDescription(): string
+    {
+        return $this->description;
+    }
+
+    public function getIconIdentifier(): string
+    {
+        return $this->iconIdentifier;
+    }
+
+    public function getSetupInstructions(): string
+    {
+        return $this->setupInstructions;
+    }
+
+    public function isDefaultProviderAllowed(): bool
+    {
+        return $this->isDefaultProviderAllowed;
+    }
+
+    public function canProcess(ServerRequestInterface $request): bool
+    {
+        return $this->getInstance()->canProcess($request);
+    }
+
+    public function isActive(MfaProviderPropertyManager $propertyManager): bool
+    {
+        return $this->getInstance()->isActive($propertyManager);
+    }
+
+    public function isLocked(MfaProviderPropertyManager $propertyManager): bool
+    {
+        return $this->getInstance()->isLocked($propertyManager);
+    }
+
+    public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        return $this->getInstance()->verify($request, $propertyManager);
+    }
+
+    public function handleRequest(
+        ServerRequestInterface $request,
+        MfaProviderPropertyManager $propertyManager,
+        string $type
+    ): ResponseInterface {
+        return $this->getInstance()->handleRequest($request, $propertyManager, $type);
+    }
+
+    public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        return $this->getInstance()->activate($request, $propertyManager);
+    }
+
+    public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        return $this->getInstance()->deactivate($request, $propertyManager);
+    }
+
+    public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        return $this->getInstance()->unlock($request, $propertyManager);
+    }
+
+    public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        return $this->getInstance()->update($request, $propertyManager);
+    }
+
+    private function getInstance(): MfaProviderInterface
+    {
+        return $this->instance ?? $this->createInstance();
+    }
+
+    private function createInstance(): MfaProviderInterface
+    {
+        $this->instance = $this->container->get($this->serviceName);
+        return $this->instance;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifestInterface.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifestInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..075b60acacfa0a10bded6655b561fb4421fcfcec
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderManifestInterface.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa;
+
+/**
+ * Provides annotated information about the MFA provider – used in various views
+ *
+ * @internal
+ */
+interface MfaProviderManifestInterface extends MfaProviderInterface
+{
+    /**
+     * Unique provider identifier
+     *
+     * @return string
+     */
+    public function getIdentifier(): string;
+
+    /**
+     * The title of the provider
+     *
+     * @return string
+     */
+    public function getTitle(): string;
+
+    /**
+     * A short description about the provider
+     *
+     * @return string
+     */
+    public function getDescription(): string;
+
+    /**
+     * Instructions to be displayed in the setup view
+     *
+     * @return string
+     */
+    public function getSetupInstructions(): string;
+
+    /**
+     * The icon identifier for this provider
+     *
+     * @return string
+     */
+    public function getIconIdentifier(): string;
+
+    /**
+     * Whether the provider is allowed to be set as default
+     *
+     * @return bool
+     */
+    public function isDefaultProviderAllowed(): bool;
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderPropertyManager.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderPropertyManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..b2846def9dc730b2d89428329e15508230e7a34e
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderPropertyManager.php
@@ -0,0 +1,220 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa;
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Database\Connection;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Basic manager for MFA providers to access and update their
+ * properties (information) from the mfa column in the user array.
+ *
+ * @internal This is an experimental TYPO3 Core API and subject to change until v11 LTS
+ */
+class MfaProviderPropertyManager implements LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    protected AbstractUserAuthentication $user;
+    protected array $mfa;
+    protected string $providerIdentifier;
+    protected array $providerProperties;
+    protected const DATABASE_FIELD_NAME = 'mfa';
+
+    public function __construct(AbstractUserAuthentication $user, string $provider)
+    {
+        $this->user = $user;
+        $this->mfa = json_decode($user->user[self::DATABASE_FIELD_NAME] ?? '', true) ?? [];
+        $this->providerIdentifier = $provider;
+        $this->providerProperties = $this->mfa[$provider] ?? [];
+    }
+
+    /**
+     * Check if a provider entry exists for the current user
+     *
+     * @return bool
+     */
+    public function hasProviderEntry(): bool
+    {
+        return isset($this->mfa[$this->providerIdentifier]);
+    }
+
+    /**
+     * Check if a provider property exists
+     *
+     * @param string $key
+     * @return bool
+     */
+    public function hasProperty(string $key): bool
+    {
+        return isset($this->providerProperties[$key]);
+    }
+
+    /**
+     * Get a provider specific property value or the defined
+     * default value if the requested property was not found.
+     *
+     * @param string $key
+     * @param null $default
+     * @return mixed|null
+     */
+    public function getProperty(string $key, $default = null)
+    {
+        return $this->providerProperties[$key] ?? $default;
+    }
+
+    /**
+     * Get provider specific properties
+     *
+     * @return array
+     */
+    public function getProperties(): array
+    {
+        return $this->providerProperties;
+    }
+
+    /**
+     * Update the provider properties or create the provider entry if not yet present
+     *
+     * @param array $properties
+     * @return bool
+     */
+    public function updateProperties(array $properties): bool
+    {
+        if (!isset($properties['updated'])) {
+            $properties['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
+        }
+
+        $this->providerProperties = array_replace($this->providerProperties, $properties) ?? [];
+        $this->mfa[$this->providerIdentifier] = $this->providerProperties;
+        return $this->storeProperties();
+    }
+
+    /**
+     * Create a new provider entry for the current user
+     * Note: If a entry already exists, use updateProperties() instead.
+     *       This can be checked with hasProviderEntry().
+     *
+     * @param array $properties
+     * @return bool
+     */
+    public function createProviderEntry(array $properties): bool
+    {
+        // This is to prevent unintentional overwriting of provider entries
+        if ($this->hasProviderEntry()) {
+            throw new \InvalidArgumentException(
+                'A entry for provider ' . $this->providerIdentifier . ' already exists. Use updateProperties() instead.',
+                1612781782
+            );
+        }
+
+        if (!isset($properties['created'])) {
+            $properties['created'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
+        }
+
+        if (!isset($properties['updated'])) {
+            $properties['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
+        }
+
+        $this->providerProperties = $properties;
+        $this->mfa[$this->providerIdentifier] = $this->providerProperties;
+        return $this->storeProperties();
+    }
+
+    /**
+     * Delete a provider entry for the current user
+     *
+     * @return bool
+     * @throws \JsonException
+     */
+    public function deleteProviderEntry(): bool
+    {
+        $this->providerProperties = [];
+        unset($this->mfa[$this->providerIdentifier]);
+        return $this->storeProperties();
+    }
+
+    /**
+     * Stores the updated properties in the user array and the database
+     *
+     * @return bool
+     * @throws \JsonException
+     */
+    protected function storeProperties(): bool
+    {
+        // encode the mfa properties to store them in the database and the user array
+        $mfa = json_encode($this->mfa, JSON_THROW_ON_ERROR) ?: '';
+
+        // Write back the updated mfa properties to the user array
+        $this->user->user[self::DATABASE_FIELD_NAME] = $mfa;
+
+        // Log MFA update
+        $this->logger->debug('MFA properties updated', [
+            'provider' => $this->providerIdentifier,
+            'user' => [
+                'uid' => $this->user->user[$this->user->userid_column],
+                'username' => $this->user->user[$this->user->username_column]
+            ]
+        ]);
+
+        // Store updated mfa properties in the database
+        return (bool)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user->user_table)->update(
+            $this->user->user_table,
+            [self::DATABASE_FIELD_NAME => $mfa],
+            [$this->user->userid_column => (int)$this->user->user[$this->user->userid_column]],
+            [self::DATABASE_FIELD_NAME => Connection::PARAM_LOB]
+        );
+    }
+
+    /**
+     * Return the current user
+     *
+     * @return AbstractUserAuthentication
+     */
+    public function getUser(): AbstractUserAuthentication
+    {
+        return $this->user;
+    }
+
+    /**
+     * Return the current providers identifier
+     *
+     * @return string
+     */
+    public function getIdentifier(): string
+    {
+        return $this->providerIdentifier;
+    }
+
+    /**
+     * Create property manager for the user with the given provider
+     *
+     * @param MfaProviderManifestInterface $provider
+     * @param AbstractUserAuthentication $user
+     * @return MfaProviderPropertyManager
+     */
+    public static function create(MfaProviderManifestInterface $provider, AbstractUserAuthentication $user): self
+    {
+        return GeneralUtility::makeInstance(self::class, $user, $provider->getIdentifier());
+    }
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderRegistry.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderRegistry.php
new file mode 100644
index 0000000000000000000000000000000000000000..b5ef3c7ca82a5ea56183bdb330775fb52ad96895
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaProviderRegistry.php
@@ -0,0 +1,149 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa;
+
+use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
+
+/**
+ * Registry for configuration providers which is called by the ConfigurationProviderPass
+ *
+ * @internal
+ */
+class MfaProviderRegistry
+{
+    /**
+     * @var MfaProviderManifestInterface[]
+     */
+    protected array $providers = [];
+
+    public function registerProvider(MfaProviderManifestInterface $provider): void
+    {
+        $this->providers[$provider->getIdentifier()] = $provider;
+    }
+
+    public function hasProvider(string $identifer): bool
+    {
+        return isset($this->providers[$identifer]);
+    }
+
+    public function hasProviders(): bool
+    {
+        return $this->providers !== [];
+    }
+
+    public function getProvider(string $identifier): MfaProviderManifestInterface
+    {
+        if (!$this->hasProvider($identifier)) {
+            throw new \InvalidArgumentException('No MFA provider for identifier ' . $identifier . 'found.', 1610994735);
+        }
+
+        return $this->providers[$identifier];
+    }
+
+    public function getProviders(): array
+    {
+        return $this->providers;
+    }
+
+    /**
+     * Whether the given user has active providers
+     *
+     * @param AbstractUserAuthentication $user
+     * @return bool
+     */
+    public function hasActiveProviders(AbstractUserAuthentication $user): bool
+    {
+        return $this->getActiveProviders($user) !== [];
+    }
+
+    /**
+     * Get all active providers for the given user
+     *
+     * @param AbstractUserAuthentication $user
+     * @return MfaProviderManifestInterface[]
+     */
+    public function getActiveProviders(AbstractUserAuthentication $user): array
+    {
+        return array_filter($this->providers, static function ($provider) use ($user) {
+            return $provider->isActive(MfaProviderPropertyManager::create($provider, $user));
+        });
+    }
+
+    /**
+     * Get the first provider for the user which can be used for authentication.
+     * This is either the user specified default provider, or the first active
+     * provider based on the providers configured ordering.
+     *
+     * @param AbstractUserAuthentication $user
+     * @return MfaProviderManifestInterface
+     */
+    public function getFirstAuthenticationAwareProvider(AbstractUserAuthentication $user): ?MfaProviderManifestInterface
+    {
+        // Since the user is not fully authenticated we need to unpack UC here to be
+        // able to retrieve a possible defined default (preferred) provider.
+        $user->unpack_uc();
+
+        $activeProviders = $this->getActiveProviders($user);
+        // If the user did not activate any provider yet, authentication is not possible
+        if ($activeProviders === []) {
+            return null;
+        }
+        // Check if the user has chosen a default (preferred) provider, which is still active
+        $defaultProvider = (string)($user->uc['mfa']['defaultProvider'] ?? '');
+        if ($defaultProvider !== '' && isset($activeProviders[$defaultProvider])) {
+            return $activeProviders[$defaultProvider];
+        }
+        // If no default provider exists or is not valid, return the first active provider
+        return array_shift($activeProviders);
+    }
+
+    /**
+     * Whether the given user has locked providers
+     *
+     * @param AbstractUserAuthentication $user
+     * @return bool
+     */
+    public function hasLockedProviders(AbstractUserAuthentication $user): bool
+    {
+        return $this->getLockedProviders($user) !== [];
+    }
+
+    /**
+     * Get all locked providers for the given user
+     *
+     * @param AbstractUserAuthentication $user
+     * @return MfaProviderManifestInterface[]
+     */
+    public function getLockedProviders(AbstractUserAuthentication $user): array
+    {
+        return array_filter($this->providers, static function ($provider) use ($user) {
+            return $provider->isLocked(MfaProviderPropertyManager::create($provider, $user));
+        });
+    }
+
+    public function allowedProvidersItemsProcFunc(array &$parameters): void
+    {
+        foreach ($this->providers as $provider) {
+            $parameters['items'][] = [
+                $provider->getTitle(),
+                $provider->getIdentifier(),
+                $provider->getIconIdentifier()
+            ];
+        }
+    }
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaRequiredException.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaRequiredException.php
new file mode 100644
index 0000000000000000000000000000000000000000..b169bd58b4ff2815d5e755cf0697a1155ce828c8
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaRequiredException.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa;
+
+use TYPO3\CMS\Core\Exception;
+
+/**
+ * This exception is thrown during the authentication process
+ * when a user has successfully passed its first authentication
+ * method (e.g. via username+password), but is required to also
+ * pass multi-factor authentication (e.g. one-time password).
+ */
+class MfaRequiredException extends Exception
+{
+    private MfaProviderManifestInterface $provider;
+
+    public function __construct(MfaProviderManifestInterface $provider, $code = 0, $message = '', \Throwable $previous = null)
+    {
+        $this->provider = $provider;
+        parent::__construct($message, $code, $previous);
+    }
+
+    public function getProvider(): MfaProviderManifestInterface
+    {
+        return $this->provider;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/MfaViewType.php b/typo3/sysext/core/Classes/Authentication/Mfa/MfaViewType.php
new file mode 100644
index 0000000000000000000000000000000000000000..6c6871306068fafba197d4d51c57e1b878ec2b6e
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/MfaViewType.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa;
+
+use TYPO3\CMS\Core\Type\Enumeration;
+
+/**
+ * Enumeration of possible view types for MFA providers
+ *
+ * @internal This is an experimental TYPO3 Core API and subject to change until v11 LTS
+ */
+class MfaViewType extends Enumeration
+{
+    public const SETUP = 'setup';
+    public const EDIT ='edit';
+    public const AUTH ='auth';
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodes.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodes.php
new file mode 100644
index 0000000000000000000000000000000000000000..3916c23b448aa98cb17b4c15dd6cf42462da0670
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodes.php
@@ -0,0 +1,136 @@
+<?php
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+declare(strict_types=1);
+
+namespace TYPO3\CMS\Core\Authentication\Mfa\Provider;
+
+use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
+use TYPO3\CMS\Core\Crypto\Random;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Implementation for generation and validation of recovery codes
+ *
+ * @internal should only be used by the TYPO3 Core
+ */
+class RecoveryCodes
+{
+    private const MIN_LENGTH = 8;
+
+    protected string $mode;
+    protected PasswordHashFactory $passwordHashFactory;
+
+    public function __construct(string $mode)
+    {
+        $this->mode = $mode;
+        $this->passwordHashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
+    }
+
+    /**
+     * Generate plain and hashed recovery codes and return them as key/value
+     *
+     * @return array
+     */
+    public function generateRecoveryCodes(): array
+    {
+        $plainCodes = $this->generatePlainRecoveryCodes();
+        return array_combine($plainCodes, $this->generatedHashedRecoveryCodes($plainCodes));
+    }
+
+    /**
+     * Generate given amount of plain recovery codes with the given length
+     *
+     * @param int $length
+     * @param int $quantity
+     * @return string[]
+     */
+    public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array
+    {
+        if ($length < self::MIN_LENGTH) {
+            throw new \InvalidArgumentException(
+                $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH,
+                1613666803
+            );
+        }
+
+        $codes = [];
+        $random = GeneralUtility::makeInstance(Random::class);
+
+        while ($quantity >= 1 && count($codes) < $quantity) {
+            $number = (int)hexdec($random->generateRandomHexString(16));
+            // We only want to work with positive integers
+            if ($number < 0) {
+                continue;
+            }
+            // Create a $length long string from the random number
+            $code = str_pad((string)($number % (10 ** $length)), $length, '0', STR_PAD_LEFT);
+            // Prevent duplicate codes which is however very unlikely to happen
+            if (!in_array($code, $codes, true)) {
+                $codes[] = $code;
+            }
+        }
+
+        return $codes;
+    }
+
+    /**
+     * Hash the given plain recovery codes with the default hash instance and return them
+     *
+     * @param array $codes
+     * @return array
+     */
+    public function generatedHashedRecoveryCodes(array $codes): array
+    {
+        // Use the current default hash instance for hashing the recovery codes
+        $hashInstance = $this->passwordHashFactory->getDefaultHashInstance($this->mode);
+
+        foreach ($codes as &$code) {
+            $code = $hashInstance->getHashedPassword($code);
+        }
+        unset($code);
+        return $codes;
+    }
+
+    /**
+     * Compare given recovery code against all hashed codes and
+     * unset the corresponding code on success.
+     *
+     * @param string $recoveryCode
+     * @param array $codes
+     * @return bool
+     */
+    public function verifyRecoveryCode(string $recoveryCode, array &$codes): bool
+    {
+        if ($codes === []) {
+            return false;
+        }
+
+        // Get the hash instance which was initially used to generate these codes.
+        // This could differ from the current default has instance. We however only need
+        // to check the first code since recovery codes can not be generated individually.
+        $hasInstance = $this->passwordHashFactory->get(reset($codes), $this->mode);
+
+        foreach ($codes as $key => $code) {
+            // Compare hashed codes
+            if ($hasInstance->checkPassword($recoveryCode, $code)) {
+                // Unset the matching code
+                unset($codes[$key]);
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..842b0765eb2b863efa5c3cde1d859c96f4e3e620
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php
@@ -0,0 +1,407 @@
+<?php
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+declare(strict_types=1);
+
+namespace TYPO3\CMS\Core\Authentication\Mfa\Provider;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Http\HtmlResponse;
+use TYPO3\CMS\Core\Http\PropagateResponseException;
+use TYPO3\CMS\Core\Http\RedirectResponse;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Messaging\FlashMessage;
+use TYPO3\CMS\Core\Messaging\FlashMessageService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Fluid\View\StandaloneView;
+
+/**
+ * MFA provider for authentication with recovery codes
+ *
+ * @internal should only be used by the TYPO3 Core
+ */
+class RecoveryCodesProvider implements MfaProviderInterface
+{
+    protected MfaProviderRegistry $mfaProviderRegistry;
+    protected Context $context;
+    protected UriBuilder $uriBuilder;
+    protected FlashMessageService $flashMessageService;
+
+    public function __construct(
+        MfaProviderRegistry $mfaProviderRegistry,
+        Context $context,
+        UriBuilder $uriBuilder,
+        FlashMessageService $flashMessageService
+    ) {
+        $this->mfaProviderRegistry = $mfaProviderRegistry;
+        $this->context = $context;
+        $this->uriBuilder = $uriBuilder;
+        $this->flashMessageService = $flashMessageService;
+    }
+
+    private const MAX_ATTEMPTS = 3;
+
+    /**
+     * Check if a recovery code is given in the current request
+     *
+     * @param ServerRequestInterface $request
+     * @return bool
+     */
+    public function canProcess(ServerRequestInterface $request): bool
+    {
+        return $this->getRecoveryCode($request) !== '';
+    }
+
+    /**
+     * Evaluate if the provider is activated by checking the
+     * active state from the provider properties. This provider
+     * furthermore has a mannerism that it only works if at least
+     * one other MFA provider is activated for the user.
+     *
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function isActive(MfaProviderPropertyManager $propertyManager): bool
+    {
+        return (bool)$propertyManager->getProperty('active')
+            && $this->activeProvidersExist($propertyManager);
+    }
+
+    /**
+     * Evaluate if the provider is temporarily locked by checking
+     * the current attempts state from the provider properties and
+     * if there are still recovery codes left.
+     *
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function isLocked(MfaProviderPropertyManager $propertyManager): bool
+    {
+        $attempts = (int)$propertyManager->getProperty('attempts', 0);
+        $codes = (array)$propertyManager->getProperty('codes', []);
+
+        // Assume the provider is locked in case either the maximum attempts are exceeded or no codes
+        // are available. A provider however can only be locked if set up - an entry exists in database.
+        return $propertyManager->hasProviderEntry() && ($attempts >= self::MAX_ATTEMPTS || $codes === []);
+    }
+
+    /**
+     * Verify the given recovery code and remove it from the
+     * provider properties if valid.
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     *
+     * @return bool
+     */
+    public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        $recoveryCode = $this->getRecoveryCode($request);
+        $codes = $propertyManager->getProperty('codes', []);
+        $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager));
+        if (!$recoveryCodes->verifyRecoveryCode($recoveryCode, $codes)) {
+            $attempts = $propertyManager->getProperty('attempts', 0);
+            $propertyManager->updateProperties(['attempts' => ++$attempts]);
+            return false;
+        }
+
+        // Since the codes were passed by reference to the verify method, the matching code was
+        // unset so we simply need to write the array back. However, if the update fails, we must
+        // return FALSE even if the authentication was successful to prevent data inconsistency.
+        return $propertyManager->updateProperties([
+            'codes' => $codes,
+            'attempts' => 0,
+            'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp')
+        ]);
+    }
+
+    /**
+     * Render the provider specific response for the given content type
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @param string $type
+     * @return ResponseInterface
+     * @throws PropagateResponseException
+     */
+    public function handleRequest(
+        ServerRequestInterface $request,
+        MfaProviderPropertyManager $propertyManager,
+        string $type
+    ): ResponseInterface {
+        $view = GeneralUtility::makeInstance(StandaloneView::class);
+        $view->setTemplateRootPaths(['EXT:core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes']);
+        switch ($type) {
+            case MfaViewType::SETUP:
+                if (!$this->activeProvidersExist($propertyManager)) {
+                    // If no active providers are present for the current user, add a flash message and redirect
+                    $lang = $this->getLanguageService();
+                    $this->addFlashMessage(
+                        $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.message'),
+                        $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.title'),
+                        FlashMessage::WARNING
+                    );
+                    if (($normalizedParams = $request->getAttribute('normalizedParams'))) {
+                        $returnUrl = $normalizedParams->getHttpReferer();
+                    } else {
+                        // @todo this will not work for FE - make this more generic!
+                        $returnUrl = $this->uriBuilder->buildUriFromRoute('mfa');
+                    }
+                    throw new PropagateResponseException(new RedirectResponse($returnUrl, 303), 1612883326);
+                }
+                $codes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatePlainRecoveryCodes();
+                $view->setTemplate('Setup');
+                $view->assignMultiple([
+                    'recoveryCodes' => implode(PHP_EOL, $codes),
+                    // Generate hmac of the recovery codes to prevent them from being changed in the setup from
+                    'checksum' => GeneralUtility::hmac(json_encode($codes), 'recovery-codes-setup')
+                ]);
+                break;
+            case MfaViewType::EDIT:
+                $view->setTemplate('Edit');
+                $view->assignMultiple([
+                    'name' => $propertyManager->getProperty('name'),
+                    'amountOfCodesLeft' => count($propertyManager->getProperty('codes', [])),
+                    'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)),
+                    'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0))
+                ]);
+                break;
+            case MfaViewType::AUTH:
+                $view->setTemplate('Auth');
+                $view->assign('isLocked', $this->isLocked($propertyManager));
+                break;
+        }
+        return new HtmlResponse($view->assign('providerIdentifier', $propertyManager->getIdentifier())->render());
+    }
+
+    /**
+     * Activate the provider by hashing and storing the given recovery codes
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        if ($this->isActive($propertyManager)) {
+            // Return since the user already activated this provider
+            return true;
+        }
+
+        $recoveryCodes = GeneralUtility::trimExplode(PHP_EOL, (string)($request->getParsedBody()['recoveryCodes'] ?? ''));
+        $checksum = (string)($request->getParsedBody()['checksum'] ?? '');
+        if ($recoveryCodes === []
+            || !hash_equals(GeneralUtility::hmac(json_encode($recoveryCodes), 'recovery-codes-setup'), $checksum)
+        ) {
+            // Return since the request does not contain the initially created recovery codes
+            return false;
+        }
+
+        // Hash given plain recovery codes and prepare the properties array with active state and custom name
+        $hashedCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatedHashedRecoveryCodes($recoveryCodes);
+        $properties = ['codes' => $hashedCodes, 'active' => true];
+        if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') {
+            $properties['name'] = $name;
+        }
+
+        // Usually there should be no entry if the provider is not activated, but to prevent the
+        // provider from being unable to activate again, we update the existing entry in such case.
+        return $propertyManager->hasProviderEntry()
+            ? $propertyManager->updateProperties($properties)
+            : $propertyManager->createProviderEntry($properties);
+    }
+
+    /**
+     * Handle the deactivate action by removing the provider entry
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        // Only check for the active property here to enable bulk deactivation,
+        // e.g. in FormEngine. Otherwise it would not be possible to deactivate
+        // this provider if the last "fully" provider was deactivated before.
+        if (!(bool)$propertyManager->getProperty('active')) {
+            // Return since this provider is not activated
+            return false;
+        }
+
+        // Delete the provider entry
+        return $propertyManager->deleteProviderEntry();
+    }
+
+    /**
+     * Handle the unlock action by resetting the attempts
+     * provider property and issuing new codes.
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        if (!$this->isLocked($propertyManager)) {
+            // Return since this provider is not locked
+            return false;
+        }
+
+        // Reset attempts
+        if ((int)$propertyManager->getProperty('attempts', 0) !== 0
+            && !$propertyManager->updateProperties(['attempts' => 0])
+        ) {
+            // Could not reset the attempts, so we can not unlock the provider
+            return false;
+        }
+
+        // Regenerate codes
+        if ($propertyManager->getProperty('codes', []) === []) {
+            // Generate new codes and store the hashed ones
+            $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes();
+            if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) {
+                // Codes could not be stored, so we can not unlock the provider
+                return false;
+            }
+            // Add the newly generated codes to a flash message so the user can copy them
+            $lang = $this->getLanguageService();
+            $this->addFlashMessage(
+                sprintf(
+                    $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.message'),
+                    implode(' ', array_keys($recoveryCodes))
+                ),
+                $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.title'),
+                FlashMessage::WARNING
+            );
+        }
+
+        return true;
+    }
+
+    public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        $name = (string)($request->getParsedBody()['name'] ?? '');
+        if ($name !== '' && !$propertyManager->updateProperties(['name' => $name])) {
+            return false;
+        }
+
+        if ((bool)($request->getParsedBody()['regenerateCodes'] ?? false)) {
+            // Generate new codes and store the hashed ones
+            $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes();
+            if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) {
+                // Codes could not be stored, so we can not update the provider
+                return false;
+            }
+            // Add the newly generated codes to a flash message so the user can copy them
+            $lang = $this->getLanguageService();
+            $this->addFlashMessage(
+                sprintf(
+                    $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.message'),
+                    implode(' ', array_keys($recoveryCodes))
+                ),
+                $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.title'),
+                FlashMessage::OK
+            );
+        }
+
+        // Provider properties successfully updated
+        return true;
+    }
+
+    /**
+     * Check if the current user has other active providers
+     *
+     * @param MfaProviderPropertyManager $currentPropertyManager
+     * @return bool
+     */
+    protected function activeProvidersExist(MfaProviderPropertyManager $currentPropertyManager): bool
+    {
+        $user = $currentPropertyManager->getUser();
+        foreach ($this->mfaProviderRegistry->getProviders() as $identifier => $provider) {
+            $propertyManager = MfaProviderPropertyManager::create($provider, $user);
+            if ($identifier !== $currentPropertyManager->getIdentifier() && $provider->isActive($propertyManager)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Internal helper method for fetching the recovery code from the request
+     *
+     * @param ServerRequestInterface $request
+     * @return string
+     */
+    protected function getRecoveryCode(ServerRequestInterface $request): string
+    {
+        return trim((string)($request->getQueryParams()['rc'] ?? $request->getParsedBody()['rc'] ?? ''));
+    }
+
+    /**
+     * Determine the mode (used for the hash instance) based on the current users table
+     *
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return string
+     */
+    protected function getMode(MfaProviderPropertyManager $propertyManager): string
+    {
+        return $propertyManager->getUser()->loginType;
+    }
+
+    /**
+     * Add a custom flash message for this provider
+     * Note: The flash messages added by the main controller are still shown to the user.
+     *
+     * @param string $message
+     * @param string $title
+     * @param int $severity
+     */
+    protected function addFlashMessage(string $message, string $title = '', int $severity = FlashMessage::INFO): void
+    {
+        $this->flashMessageService->getMessageQueueByIdentifier()->enqueue(
+            GeneralUtility::makeInstance(FlashMessage::class, $message, $title, $severity, true)
+        );
+    }
+
+    /**
+     * Return the timestamp as local time (date string) by applying the globally configured format
+     *
+     * @param int $timestamp
+     * @return string
+     */
+    protected function getDateTime(int $timestamp): string
+    {
+        if ($timestamp === 0) {
+            return '';
+        }
+
+        return date(
+            $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'],
+            $timestamp
+        ) ?: '';
+    }
+
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/Totp.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/Totp.php
new file mode 100644
index 0000000000000000000000000000000000000000..48c67085a2222f59a121a8773f19834cd539b721
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/Totp.php
@@ -0,0 +1,220 @@
+<?php
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+declare(strict_types=1);
+
+namespace TYPO3\CMS\Core\Authentication\Mfa\Provider;
+
+use Base32\Base32;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Time-based one-time password (TOTP) implementation according to rfc6238
+ *
+ * @internal should only be used by the TYPO3 Core
+ */
+class Totp
+{
+    private const ALLOWED_ALGOS = ['sha1', 'sha256', 'sha512'];
+    private const MIN_LENGTH = 6;
+    private const MAX_LENGTH = 8;
+
+    protected string $secret;
+    protected string $algo;
+    protected int $length;
+    protected int $step;
+    protected int $epoch;
+
+    public function __construct(
+        string $secret,
+        string $algo = 'sha1',
+        int $length = 6,
+        int $step = 30,
+        int $epoch = 0
+    ) {
+        $this->secret = $secret;
+        $this->step = $step;
+        $this->epoch = $epoch;
+
+        if (!in_array($algo, self::ALLOWED_ALGOS, true)) {
+            throw new \InvalidArgumentException(
+                $algo . ' is not allowed. Allowed algos are: ' . implode(',', self::ALLOWED_ALGOS),
+                1611748791
+            );
+        }
+        $this->algo = $algo;
+
+        if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
+            throw new \InvalidArgumentException(
+                $length . ' is not allowed as TOTP length. Must be between ' . self::MIN_LENGTH . ' and ' . self::MAX_LENGTH,
+                1611748792
+            );
+        }
+        $this->length = $length;
+    }
+
+    /**
+     * Generate a time-based one-time password for the given counter according to rfc4226
+     *
+     * @param int $counter A timestamp (counter) according to rfc6238
+     * @return string The generated TOTP
+     */
+    public function generateTotp(int $counter): string
+    {
+        // Generate a 8-byte counter value (C) from the given counter input
+        $binary = [];
+        while ($counter !== 0) {
+            $binary[] = pack('C*', $counter);
+            $counter >>= 8;
+        }
+        // Implode and fill with NULL values
+        $binary = str_pad(implode(array_reverse($binary)), 8, "\000", STR_PAD_LEFT);
+        // Create a 20-byte hash string (HS) with given algo and decoded shared secret (K)
+        $hash = hash_hmac($this->algo, $binary, $this->getDecodedSecret());
+        // Convert hash into hex and generate an array with the decimal values of the hash
+        $hmac = [];
+        foreach (str_split($hash, 2) as $hex) {
+            $hmac[] = hexdec($hex);
+        }
+        // Generate a 4-byte string with dynamic truncation (DT)
+        $offset = $hmac[\count($hmac) - 1] & 0xf;
+        $bits = ((($hmac[$offset + 0] & 0x7f) << 24) | (($hmac[$offset + 1] & 0xff) << 16) | (($hmac[$offset + 2] & 0xff) << 8) | ($hmac[$offset + 3] & 0xff));
+        // Compute the TOTP value by reducing the bits modulo 10^Digits and filling it with zeros '0'
+        return str_pad((string)($bits % (10 ** $this->length)), $this->length, '0', STR_PAD_LEFT);
+    }
+
+    /**
+     * Verify the given time-based one-time password
+     *
+     * @param string $totp The time-based one-time password to be verified
+     * @param int|null $gracePeriod The grace period for the TOTP +- (mainly to circumvent transmission delays)
+     * @return bool
+     */
+    public function verifyTotp(string $totp, int $gracePeriod = null): bool
+    {
+        $counter = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
+
+        // If no grace period is given, only check once
+        if ($gracePeriod === null) {
+            return $this->compare($totp, $this->getTimeCounter($counter));
+        }
+
+        // Check the token within the given grace period till it can be verified or the grace period is exhausted
+        for ($i = 0; $i < $gracePeriod; ++$i) {
+            $next = $i * $this->step + $counter;
+            $prev = $counter - $i * $this->step;
+            if ($this->compare($totp, $this->getTimeCounter($next))
+                || $this->compare($totp, $this->getTimeCounter($prev))
+            ) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Generate and return the otpauth URL for TOTP
+     *
+     * @param string $issuer
+     * @param string $account
+     * @param array $additionalParameters
+     * @return string
+     */
+    public function getTotpAuthUrl(string $issuer, string $account = '', array $additionalParameters = []): string
+    {
+        $parameters = [
+            'secret' => $this->secret,
+            'issuer' => htmlspecialchars($issuer)
+        ];
+
+        // Common OTP applications expect the following parameters:
+        // - algo: sha1
+        // - period: 30 (in seconds)
+        // - digits 6
+        // - epoch: 0
+        // Only if we differ from these assumption, the exact values must be provided.
+        if ($this->algo !== 'sha1') {
+            $parameters['algorithm'] = $this->algo;
+        }
+        if ($this->step !== 30) {
+            $parameters['period'] = $this->step;
+        }
+        if ($this->length !== 6) {
+            $parameters['digits'] = $this->length;
+        }
+        if ($this->epoch !== 0) {
+            $parameters['epoch'] = $this->epoch;
+        }
+
+        // Generate the otpauth URL by providing information like issuer and account
+        return sprintf(
+            'otpauth://totp/%s?%s',
+            rawurlencode($issuer . ($account !== '' ? ':' . $account : '')),
+            http_build_query(array_merge($parameters, $additionalParameters), '', '&', PHP_QUERY_RFC3986)
+        );
+    }
+
+    /**
+     * Compare given time-based one-time password with a time-based one-time
+     * password generated from the known $counter (the moving factor).
+     *
+     * @param string $totp The time-based one-time password to verify
+     * @param int $counter The counter value, the moving factor
+     * @return bool
+     */
+    protected function compare(string $totp, int $counter): bool
+    {
+        return hash_equals($this->generateTotp($counter), $totp);
+    }
+
+    /**
+     * Generate the counter value (moving factor) from the given timestamp
+     *
+     * @param int $timestamp
+     * @return int
+     */
+    protected function getTimeCounter(int $timestamp): int
+    {
+        return (int)floor(($timestamp - $this->epoch) / $this->step);
+    }
+
+    /**
+     * Generate the shared secret (K) by using a random and applying
+     * additional authentication factors like username or email address.
+     *
+     * @param array $additionalAuthFactors
+     * @return string
+     */
+    public static function generateEncodedSecret(array $additionalAuthFactors = []): string
+    {
+        $secret = '';
+        $payload = implode($additionalAuthFactors);
+        // Prevent secrets with a trailing pad character since this will eventually break the QR-code feature
+        while ($secret === '' || strpos($secret, '=') !== false) {
+            // RFC 4226 (https://tools.ietf.org/html/rfc4226#section-4) suggests 160 bit TOTP secret keys
+            // HMAC-SHA1 based on static factors and a 160 bit HMAC-key lead again to 160 bits (20 bytes)
+            // base64-encoding (factor 1.6) 20 bytes lead to 32 uppercase characters
+            $secret = Base32::encode(hash_hmac('sha1', $payload, random_bytes(20), true));
+        }
+        return $secret;
+    }
+
+    protected function getDecodedSecret(): string
+    {
+        return Base32::decode($this->secret);
+    }
+}
diff --git a/typo3/sysext/core/Classes/Authentication/Mfa/Provider/TotpProvider.php b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/TotpProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d7f036eddaeb0eac5eccee696a8866bd6076f76
--- /dev/null
+++ b/typo3/sysext/core/Classes/Authentication/Mfa/Provider/TotpProvider.php
@@ -0,0 +1,345 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Authentication\Mfa\Provider;
+
+use BaconQrCode\Renderer\Image\SvgImageBackEnd;
+use BaconQrCode\Renderer\ImageRenderer;
+use BaconQrCode\Renderer\RendererStyle\RendererStyle;
+use BaconQrCode\Writer;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Http\HtmlResponse;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Mvc\View\ViewInterface;
+use TYPO3\CMS\Fluid\View\StandaloneView;
+
+/**
+ * MFA provider for time-based one-time password authentication
+ *
+ * @internal should only be used by the TYPO3 Core
+ */
+class TotpProvider implements MfaProviderInterface
+{
+    private const MAX_ATTEMPTS = 3;
+
+    protected Context $context;
+
+    public function __construct(Context $context)
+    {
+        $this->context = $context;
+    }
+
+    /**
+     * Check if a TOTP is given in the current request
+     *
+     * @param ServerRequestInterface $request
+     * @return bool
+     */
+    public function canProcess(ServerRequestInterface $request): bool
+    {
+        return $this->getTotp($request) !== '';
+    }
+
+    /**
+     * Evaluate if the provider is activated by checking
+     * the active state from the provider properties.
+     *
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function isActive(MfaProviderPropertyManager $propertyManager): bool
+    {
+        return (bool)$propertyManager->getProperty('active');
+    }
+
+    /**
+     * Evaluate if the provider is temporarily locked by checking
+     * the current attempts state from the provider properties.
+     *
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function isLocked(MfaProviderPropertyManager $propertyManager): bool
+    {
+        $attempts = (int)$propertyManager->getProperty('attempts', 0);
+
+        // Assume the provider is locked in case the maximum attempts are exceeded.
+        // A provider however can only be locked if set up - an entry exists in database.
+        return $propertyManager->hasProviderEntry() && $attempts >= self::MAX_ATTEMPTS;
+    }
+
+    /**
+     * Verify the given TOTP and update the provider properties in case the TOTP is valid.
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        $totp = $this->getTotp($request);
+        $secret = $propertyManager->getProperty('secret', '');
+        $verified = GeneralUtility::makeInstance(Totp::class, $secret)->verifyTotp($totp, 2);
+        if (!$verified) {
+            $attempts = $propertyManager->getProperty('attempts', 0);
+            $propertyManager->updateProperties(['attempts' => ++$attempts]);
+            return false;
+        }
+        $propertyManager->updateProperties([
+            'attempts' => 0,
+            'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp')
+        ]);
+        return true;
+    }
+
+    /**
+     * Activate the provider by checking the necessary parameters,
+     * verifying the TOTP and storing the provider properties.
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        if ($this->isActive($propertyManager)) {
+            // Return since the user already activated this provider
+            return true;
+        }
+
+        if (!$this->canProcess($request)) {
+            // Return since the request can not be processed by this provider
+            return false;
+        }
+
+        $secret = (string)($request->getParsedBody()['secret'] ?? '');
+        $checksum = (string)($request->getParsedBody()['checksum'] ?? '');
+        if ($secret === '' || !hash_equals(GeneralUtility::hmac($secret, 'totp-setup'), $checksum)) {
+            // Return since the request does not contain the initially created secret
+            return false;
+        }
+
+        $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret);
+        if (!$totpInstance->verifyTotp($this->getTotp($request), 2)) {
+            // Return since the given TOTP could not be verified
+            return false;
+        }
+
+        // If valid, prepare the provider properties to be stored
+        $properties = ['secret' => $secret, 'active' => true];
+        if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') {
+            $properties['name'] = $name;
+        }
+
+        // Usually there should be no entry if the provider is not activated, but to prevent the
+        // provider from being unable to activate again, we update the existing entry in such case.
+        return $propertyManager->hasProviderEntry()
+            ? $propertyManager->updateProperties($properties)
+            : $propertyManager->createProviderEntry($properties);
+    }
+
+    /**
+     * Handle the save action by updating the provider properties
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        $name = (string)($request->getParsedBody()['name'] ?? '');
+        if ($name !== '') {
+            return $propertyManager->updateProperties(['name' => $name]);
+        }
+
+        // Provider properties successfully updated
+        return true;
+    }
+
+    /**
+     * Handle the unlock action by resetting the attempts provider property
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        if (!$this->isLocked($propertyManager)) {
+            // Return since this provider is not locked
+            return false;
+        }
+
+        // Reset the attempts
+        return $propertyManager->updateProperties(['attempts' => 0]);
+    }
+
+    /**
+     * Handle the deactivate action. For security reasons, the provider entry
+     * is completely deleted and setting up this provider again, will therefore
+     * create a brand new entry.
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @return bool
+     */
+    public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
+    {
+        if (!$this->isActive($propertyManager)) {
+            // Return since this provider is not activated
+            return false;
+        }
+
+        // Delete the provider entry
+        return $propertyManager->deleteProviderEntry();
+    }
+
+    /**
+     * Initialize view and forward to the appropriate implementation
+     * based on the view type to be returned.
+     *
+     * @param ServerRequestInterface $request
+     * @param MfaProviderPropertyManager $propertyManager
+     * @param string $type
+     * @return ResponseInterface
+     */
+    public function handleRequest(
+        ServerRequestInterface $request,
+        MfaProviderPropertyManager $propertyManager,
+        string $type
+    ): ResponseInterface {
+        $view = GeneralUtility::makeInstance(StandaloneView::class);
+        $view->setTemplateRootPaths(['EXT:core/Resources/Private/Templates/Authentication/MfaProvider/Totp']);
+        switch ($type) {
+            case MfaViewType::SETUP:
+                $this->prepareSetupView($view, $propertyManager);
+                break;
+            case MfaViewType::EDIT:
+                $this->prepareEditView($view, $propertyManager);
+                break;
+            case MfaViewType::AUTH:
+                $this->prepareAuthView($view, $propertyManager);
+                break;
+        }
+        return new HtmlResponse($view->assign('providerIdentifier', $propertyManager->getIdentifier())->render());
+    }
+
+    /**
+     * Generate a new shared secret, generate the otpauth URL and create a qr-code
+     * for improved usability. Set template and assign necessary variables for the
+     * setup view.
+     *
+     * @param ViewInterface $view
+     * @param MfaProviderPropertyManager $propertyManager
+     */
+    protected function prepareSetupView(ViewInterface $view, MfaProviderPropertyManager $propertyManager): void
+    {
+        $userData = $propertyManager->getUser()->user ?? [];
+        $secret = Totp::generateEncodedSecret([(string)($userData['uid'] ?? ''), (string)($userData['username'] ?? '')]);
+        $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret);
+        $view->setTemplate('Setup');
+        $view->assignMultiple([
+            'secret' => $secret,
+            'qrCode' => $this->getSvgQrCode($totpInstance, $userData),
+            // Generate hmac of the secret to prevent it from being changed in the setup from
+            'checksum' => GeneralUtility::hmac($secret, 'totp-setup')
+        ]);
+    }
+
+    /**
+     * Set the template and assign necessary variables for the edit view
+     *
+     * @param ViewInterface $view
+     * @param MfaProviderPropertyManager $propertyManager
+     */
+    protected function prepareEditView(ViewInterface $view, MfaProviderPropertyManager $propertyManager): void
+    {
+        $view->setTemplate('Edit');
+        $view->assignMultiple([
+            'name' => $propertyManager->getProperty('name'),
+            'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)),
+            'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0))
+        ]);
+    }
+
+    /**
+     * Set the template for the auth view where the user has to provide the TOTP
+     *
+     * @param ViewInterface $view
+     * @param MfaProviderPropertyManager $propertyManager
+     */
+    protected function prepareAuthView(ViewInterface $view, MfaProviderPropertyManager $propertyManager): void
+    {
+        $view->setTemplate('Auth');
+        $view->assign('isLocked', $this->isLocked($propertyManager));
+    }
+
+    /**
+     * Internal helper method for fetching the TOTP from the request
+     *
+     * @param ServerRequestInterface $request
+     * @return string
+     */
+    protected function getTotp(ServerRequestInterface $request): string
+    {
+        return trim((string)($request->getQueryParams()['totp'] ?? $request->getParsedBody()['totp'] ?? ''));
+    }
+
+    /**
+     * Internal helper method for generating a svg QR-code for TOTP applications
+     *
+     * @param Totp $totp
+     * @param array $userData
+     * @return string
+     */
+    protected function getSvgQrCode(Totp $totp, array $userData): string
+    {
+        $qrCodeRenderer = new ImageRenderer(
+            new RendererStyle(225, 4),
+            new SvgImageBackEnd()
+        );
+
+        $content = $totp->getTotpAuthUrl(
+            (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? 'TYPO3'),
+            (string)($userData['email'] ?? '') ?: (string)($userData['username'] ?? '')
+        );
+        return (new Writer($qrCodeRenderer))->writeString($content);
+    }
+
+    /**
+     * Return the timestamp as local time (date string) by applying the globally configured format
+     *
+     * @param int $timestamp
+     * @return string
+     */
+    protected function getDateTime(int $timestamp): string
+    {
+        if ($timestamp === 0) {
+            return '';
+        }
+
+        return date(
+            $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'],
+            $timestamp
+        ) ?: '';
+    }
+}
diff --git a/typo3/sysext/core/Classes/DependencyInjection/MfaProviderPass.php b/typo3/sysext/core/Classes/DependencyInjection/MfaProviderPass.php
new file mode 100644
index 0000000000000000000000000000000000000000..2467a4900e80d674669f0d3e60fff9af7c935b10
--- /dev/null
+++ b/typo3/sysext/core/Classes/DependencyInjection/MfaProviderPass.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\DependencyInjection;
+
+use Psr\Container\ContainerInterface;
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Definition;
+use Symfony\Component\DependencyInjection\Reference;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifest;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Service\DependencyOrderingService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Compiler pass to register tagged MFA providers
+ *
+ * @internal
+ */
+final class MfaProviderPass implements CompilerPassInterface
+{
+    protected string $tagName;
+
+    public function __construct(string $tagName)
+    {
+        $this->tagName = $tagName;
+    }
+
+    public function process(ContainerBuilder $container): void
+    {
+        $mfaProviderRegistryDefinition = $container->findDefinition(MfaProviderRegistry::class);
+        $providers = [];
+
+        foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) {
+            $definition = $container->findDefinition($id);
+            if (!$definition->isAutoconfigured() || $definition->isAbstract()) {
+                continue;
+            }
+
+            $definition->setPublic(true);
+
+            foreach ($tags as $attributes) {
+                $identifier = $attributes['identifier'] ?? $id;
+                $providers[$identifier] = [
+                    'title' => $attributes['title'] ?? '',
+                    'description' => $attributes['description'] ?? '',
+                    'setupInstructions' => $attributes['setupInstructions'] ?? '',
+                    'iconIdentifier' => $attributes['icon'] ?? '',
+                    'isDefaultProviderAllowed' => (bool)($attributes['defaultProviderAllowed'] ?? true),
+                    'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true),
+                    'after'  => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true),
+                    'serviceName' => $id
+                ];
+            }
+        }
+
+        foreach ((new DependencyOrderingService())->orderByDependencies($providers) as $identifier => $properties) {
+            $manifest = new Definition(MfaProviderManifest::class);
+            $manifest->setArguments([
+                $identifier,
+                $properties['title'],
+                $properties['description'],
+                $properties['setupInstructions'],
+                $properties['iconIdentifier'],
+                $properties['isDefaultProviderAllowed'],
+                $properties['serviceName'],
+                new Reference(ContainerInterface::class)
+            ]);
+            $manifest->setShared(false);
+
+            $mfaProviderRegistryDefinition->addMethodCall('registerProvider', [$manifest]);
+        }
+    }
+}
diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php
index 44e066597101008fffbf2f0a5499dec6f64b9576..ae175d9459490aa5b2153c582e707a55f7a493be 100644
--- a/typo3/sysext/core/Configuration/DefaultConfiguration.php
+++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php
@@ -1145,6 +1145,8 @@ return [
         'warning_mode' => 0,
         'passwordReset' => true,
         'passwordResetForAdmins' => true,
+        'requireMfa' => 0,
+        'recommendedMfaProvider' => 'totp',
         'lockIP' => 0,
         'lockIPv6' => 0,
         'sessionTimeout' => 28800,  // a backend user logged in for 8 hours
diff --git a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml
index 9487cbd78038bd3e9f5e2e84a7783a4dbfa49a8b..c0725a47da8ab438131b4705810fbcbe3b11c612 100644
--- a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml
+++ b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml
@@ -263,6 +263,17 @@ BE:
         passwordResetForAdmins:
           type: bool
           description: 'Enable password reset functionality on the backend login for TYPO3 Administrators as well. Disable this option for increased security.'
+        requireMfa:
+          type: int
+          allowedValues:
+            '0': 'Do not require multi-factor authentication'
+            '1': 'Require multi-factor authentication for all users'
+            '2': 'Require multi-factor authentication only for non-admin users'
+            '3': 'Require multi-factor authentication only for admin users'
+          description: 'Define users which should be required to set up multi-factor authentication.'
+        recommendedMfaProvider:
+          type: text
+          description: 'Set the identifier of the multi-factor authentication provider, recommended for all users.'
         lockIP:
             type: int
             allowedValues:
diff --git a/typo3/sysext/core/Configuration/Services.php b/typo3/sysext/core/Configuration/Services.php
index 70113fee07d853787ef1ed579113a34fa6acb67a..051acc5c8693fff4ff5be6459fe555c99a1a0223 100644
--- a/typo3/sysext/core/Configuration/Services.php
+++ b/typo3/sysext/core/Configuration/Services.php
@@ -19,6 +19,7 @@ return function (ContainerConfigurator $container, ContainerBuilder $containerBu
 
     $containerBuilder->addCompilerPass(new DependencyInjection\SingletonPass('typo3.singleton'));
     $containerBuilder->addCompilerPass(new DependencyInjection\LoggerAwarePass('psr.logger_aware'));
+    $containerBuilder->addCompilerPass(new DependencyInjection\MfaProviderPass('mfa.provider'));
     $containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener'));
     $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware'));
     $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler'));
diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml
index 5fa0ced8ed0fd330f3c038b079867e2e76d641d3..4c647d08fdd3b2b1bd7da48b6cdc50b0fdee5d53 100644
--- a/typo3/sysext/core/Configuration/Services.yaml
+++ b/typo3/sysext/core/Configuration/Services.yaml
@@ -68,6 +68,31 @@ services:
   TYPO3\CMS\Core\Controller\FileDumpController:
     public: true
 
+  TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry:
+    public: true
+
+  TYPO3\CMS\Core\Authentication\Mfa\Provider\TotpProvider:
+    tags:
+      - name: mfa.provider
+        identifier: 'totp'
+        title: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.title'
+        description: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.description'
+        setupInstructions: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.setupInstructions'
+        icon: 'content-coffee'
+        defaultProviderAllowed: true
+        before: 'recovery-codes'
+
+  TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodesProvider:
+    tags:
+      - name: mfa.provider
+        identifier: 'recovery-codes'
+        title: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.title'
+        description: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.description'
+        setupInstructions: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.setupInstructions'
+        icon: 'content-text-columns'
+        defaultProviderAllowed: false
+        after: 'totp'
+
   TYPO3\CMS\Core\Core\ClassLoadingInformation:
     public: false
     tags:
diff --git a/typo3/sysext/core/Configuration/TCA/be_groups.php b/typo3/sysext/core/Configuration/TCA/be_groups.php
index 5944200f6f39229728412eb644dcc462adf8151b..896f109bfef17e42a3219579cbbb6ff8e14d2fed 100644
--- a/typo3/sysext/core/Configuration/TCA/be_groups.php
+++ b/typo3/sysext/core/Configuration/TCA/be_groups.php
@@ -213,6 +213,16 @@ return [
                 'autoSizeMax' => 50,
             ]
         ],
+        'mfa_providers' => [
+            'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:mfa_providers',
+            'config' => [
+                'type' => 'select',
+                'renderType' => 'selectCheckBox',
+                'itemsProcFunc' => \TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry::class . '->allowedProvidersItemsProcFunc',
+                'size' => 5,
+                'autoSizeMax' => 50,
+            ]
+        ],
         'description' => [
             'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.description',
             'config' => [
@@ -268,7 +278,7 @@ return [
             --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general,
                 title,subgroup,
             --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tabs.base_rights,
-                groupMods, tables_select, tables_modify, pagetypes_select, non_exclude_fields, explicit_allowdeny, allowed_languages, custom_options,
+                groupMods, mfa_providers, tables_select, tables_modify, pagetypes_select, non_exclude_fields, explicit_allowdeny, allowed_languages, custom_options,
             --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tabs.mounts_and_workspaces,
                 workspace_perms, db_mountpoints, file_mountpoints, file_permissions, category_perms,
             --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tabs.options,
diff --git a/typo3/sysext/core/Configuration/TCA/be_users.php b/typo3/sysext/core/Configuration/TCA/be_users.php
index f54f542f85f5b1f52a4af02a0de4e81509d103bd..52be0d2ca9906469271920be8a0729f6bac495ef 100644
--- a/typo3/sysext/core/Configuration/TCA/be_users.php
+++ b/typo3/sysext/core/Configuration/TCA/be_users.php
@@ -58,6 +58,14 @@ return [
                 'autocomplete' => false,
             ]
         ],
+        'mfa' => [
+            'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.mfa',
+            'config' => [
+                'type' => 'none',
+                'renderType' => 'mfaInfo',
+                'eval' => 'password' // Fallback to prevent raw data being displayed in the backend
+            ]
+        ],
         'usergroup' => [
             'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.usergroup',
             'config' => [
@@ -346,7 +354,7 @@ return [
     'types' => [
         '0' => ['showitem' => '
             --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general,
-                disable, admin, username, password, avatar, usergroup, realName, email, lang, lastlogin,
+                disable, admin, username, password, mfa, avatar, usergroup, realName, email, lang, lastlogin,
             --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.tabs.rights,
                 userMods, allowed_languages,
             --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.tabs.mounts_and_workspaces,
@@ -361,7 +369,7 @@ return [
         '],
         '1' => ['showitem' => '
             --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general,
-                disable, admin, username, password, avatar, usergroup, realName, email, lang, lastlogin,
+                disable, admin, username, password, mfa, avatar, usergroup, realName, email, lang, lastlogin,
             --div--;LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.tabs.options,
                 TSconfig, db_mountpoints, options, file_mountpoints,
             --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-93526-MultiFactorAuthentication.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-93526-MultiFactorAuthentication.rst
new file mode 100644
index 0000000000000000000000000000000000000000..80a80cbcdb30c2271061c58ce0d4611bf2acc744
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-93526-MultiFactorAuthentication.rst
@@ -0,0 +1,276 @@
+
+.. include:: ../../Includes.txt
+
+=============================================
+Feature: #93526 - Multi-Factor Authentication
+=============================================
+
+See :issue:`93526`
+
+Description
+===========
+
+TYPO3 is now capable of authentication via multiple factors, in short
+"multi-factor authentication" or "MFA". This is sometimes also referred to
+"2FA" as a 2-Factor Authentication process, where - in order to log in - the user
+needs
+
+1) "something you know" (= the password) and
+2) "something you own" (= an authenticator device, or an authenticator app
+   on mobile phones or desktop devices).
+
+Read more about the concepts of MFA here https://en.wikipedia.org/wiki/Multi-factor_authentication
+
+TYPO3 ships with some built-in MFA providers by default. But more importantly,
+TYPO3 now provides an API to allow extension authors to integrate their own
+MFA providers.
+
+The API is designed in a way to allow providers to be used for TYPO3 Backend
+Authentication or Frontend Authentication with a multi-factor step in-between.
+
+TYPO3 Core currently provides the integration for the TYPO3 Backend, but will
+fully support multi-factor authentication for the Frontend in future releases.
+
+Impact
+======
+
+Managing MFA providers is currently accessible via the User Settings module in
+the new tab called "Account security", which was previously called just
+"Password". The Account security tab displays the current state, if MFA can
+be configured, is activated or additional providers can be configured.
+
+By default, MFA can be configured for every backend user. It is possible to
+disable this field for editors via userTSconfig:
+
+.. code-block:: typoscript
+
+   setup.fields.mfaProviders.disabled = 1
+
+The MFA configuration module displays all registered providers in an overview.
+
+Included MFA providers
+----------------------
+
+TYPO3 Core includes two MFA providers:
+
+1. Time-based one-time password (TOTP)
+
+The most common MFA implementation. A QR-code is scanned (or alternatively,
+a shared secret can be entered) to connect an Authenticator app such as Google
+Authenticator, Microsoft Authenticator, 1Password, Authly or others to the
+system and then synchronize a token, which changes every 30 seconds.
+
+On each log-in, after successfully entering the password, the 6-digit code
+shown of the Authenticator App must be entered.
+
+2. Recovery codes
+
+This is a special provider which can only be activated if at least one other
+provider is active, as it's only meant as a fallback provider, in case the
+authentication credentials for the "main" provider(s) are lost. It is encouraged
+to activate this provider, and keep the codes at a safe place.
+
+Setting up MFA for a backend user
+---------------------------------
+
+Each provider is displayed with its icon, the name and a short description.
+In case a provider is active this is indicated by a corresponding label, next to
+the providers' title. The same goes for a locked provider - an active provider,
+which can currently not be used since the provider specific implementation
+detected some unusual behaviour, e.g. to many false authentication attempts.
+Furthermore does the configured default provider indicate this state with a
+"star" icon, next to the providers title.
+
+Each inactive provider contains a "Setup" button which opens the corresponding
+configuration view. This view can be different depending on the MFA provider.
+
+Each provider contains an "Edit / Change" button, which allows to adjust the
+providers' setting. This view allows for example to set a provider as the
+default (primary) provider, to be used on authentication. Note that the
+default provider setting will be automatically applied on activation of the
+first provider or in case it is the recommended provider for this user.
+
+In case the provider is locked, the "Edit / Change" button changes its button
+title to "Unlock". This button can therefore be used to unlock the provider.
+This, depending on the provider to unlock, may require further actions by the
+user.
+
+Another view is the "Authentication view", which is displayed as soon as a user
+with at least one active provider has successfully passed the username and
+password mask.
+
+As for the other views, it is up to the specific provider, used for the current
+multi-factor authentication attempt, what content is displayed in this view.
+In any case, if the user has further active providers, the view displays them
+as "Alternative providers" in the footer. So the user can switch between all
+activated providers on every authentication attempt.
+
+All providers need to define a locking functionality. In case of the TOTP
+and recovery code providers, this e.g. includes an attempts count. Therefore,
+these providers are locked in case a wrong OTP was entered three times in a
+row. The attempts count is automatically reset as soon as a correct OTP is
+entered or the user unlocks the provider in the backend.
+
+All Core providers also feature the "Last used" and "Last updated" information
+which can be retrieved in the "Edit / Change" view.
+
+**Administration of users' MFA providers**
+
+If a user is not able to access the backend anymore, e.g. because all of their
+active providers are locked, MFA needs to be disabled by an administrator for
+this specific user.
+
+Administrators are able to manage users' MFA providers in the corresponding
+user record. The new `Multi-factor authentication` field displays a
+list of active providers and a button to deactivate MFA for the user, or
+only a specific MFA provider.
+
+Note that all of these deactivate buttons are executed immediately, after
+confirming the appearing dialog, and can't be undone.
+
+The backend users listing in the backend user module also displays the current
+MFA status ("enabled", "disabled" or "locked") for each user. This allows an
+administrator a quick glance of the MFA usage of their users.
+
+Via the System => Configuration admin module, it's possible to get an overview
+of all currently registered providers in the installation. This is especially
+helpful to find out the exact provider identifier, needed for some
+userTSconfig options.
+
+Configuration
+-------------
+
+**Enforcing MFA for users**
+
+It seems reasonable to require MFA for specific users or user groups. This can
+be achieved with :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['requireMFA']` which
+allows 4 options:
+
+* `0`: Do not require multi-factor authentication (default)
+* `1`: Require multi-factor authentication for all users
+* `2`: Require multi-factor authentication only for non-admin users
+* `3`: Require multi-factor authentication only for admin users
+
+To set this requirement only for a specific user or user group, a new
+userTSconfig option `auth.mfa.required` is introduced. The userTSconfig option
+overrules the global configuration.
+
+.. code-block:: typoscript
+
+   auth.mfa.required = 1
+
+.. note::
+
+   Requiring MFA has currently limited effect. Only an information is
+   displayed in the MFA configuration module. This will change in future
+   releases when this setting will require MFA being configured on accessing
+   the TYPO3 Backend the first time. You are still already able to try it out.
+
+**Allowed provider**
+
+It is possible to only allow a subset of the available providers for some users
+or user groups.
+
+A new configuration option "Allowed multi-factor authentication providers" is
+available in the users and user groups record in the "Access Rights/List" tab.
+
+Note: Defining allowed MFA providers in a user record extends the settings
+already done in user groups records the user is attached to.
+
+There may surely be use cases in which just a single provider should be
+disallowed for a specific user, which is however configured to be allowed in
+one of the assigned user groups. Another use case is to disallow providers
+for admin users, which simply do not have the "Access Rights/List" tab.
+Therefore, the new userTSconfig option `auth.mfa.disableProviders` can be used.
+It overrules the configuration from the "Access Rights/List", which means if
+a provider is allowed in "Access Rights/List" but disallowed via userTSconfig,
+it will be disallowed for the user or user group the TSconfig applies to.
+
+This does not affect the remaining allowed providers from the
+"Access Rights/List".
+
+.. code-block:: typoscript
+
+   auth.mfa.disableProviders := addToList(totp)
+
+**Recommended provider**
+
+To recommend a specific provider, :php:`$GLOBALS['TYPO3_CONF_VARS]['BE]['recommendedMfaProvider']`
+can be used and is set to `totp` (Time-based one-time password) by default.
+
+To set a recommended provider on a per user or user group basis, the new
+userTSconfig option `auth.mfa.recommendedProvider` can be used, which overrules
+the global configuration.
+
+.. code-block:: typoscript
+
+   auth.mfa.recommendedProvider = totp
+
+TYPO3 Integration and API
+-------------------------
+
+.. important::
+
+   The MFA API is still experimental and subject to change until v11 LTS,
+   since we are looking forward to receive feedback, especially for custom
+   use-cases, the API is not capable yet.
+
+To register a custom MFA provider, the provider class has to implement the new
+:php:`MfaProviderInterface`, shipped via a third-party extension. The provider
+then has to be configured in the extensions' :file:`Services.yaml` or
+:file:`Services.php` file with the :yaml:`mfa.provider` tag.
+
+.. code-block:: yaml
+
+   Vender\Extension\Authentication\Mfa\MyProvider:
+      tags:
+         - name: mfa.provider
+           identifier: 'my-provider'
+           title: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.title'
+           description: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.description'
+           setupInstructions: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.setupInstructions'
+           icon: 'tx-extension-provider-icon'
+
+This will register the provider `MyProvider` with the `my-provider` identifier.
+To change the position of your provider the :yaml:`before` and :yaml:`after`
+arguments can be useful. This can be needed if you e.g. like your provider to
+show up prior to any other provider in the MFA configuration module. The
+ordering is also taken into account in the authentication step while logging
+in. Note that the user defined default provider will always take precedence.
+
+If you dont want your provider to be selectable as a default provider, set the
+:yaml:`defaultProviderAllowed` argument to `false`.
+
+You can also completely deactivate existing providers with:
+
+.. code-block:: yaml
+
+   TYPO3\CMS\Core\Authentication\Mfa\Provider\TotpProvider: ~
+
+The :php:`MfaProviderInterface` contains a lot of methods to be implemented by
+the providers. This can be split up into state-providing ones,
+e.g. :php:`isActive` or :php:`isLocked` and functional ones,
+e.g. :php:`activate` or :php:`update`.
+
+Their exact task is explained in the corresponding PHPDoc of the Interface files
+and the Core MFA provider implementations.
+
+All of these methods are recieving either the current PSR-7 Request object, the
+:php:`MfaProviderPropertyManager` or both. The :php:`MfaProviderPropertyManager`
+can be used to retreive and update the provider specific properties and
+also contains the :php:`getUser` method, providing the current user object.
+
+To store provider specific data, the MFA API uses a new database field `mfa`,
+which can be freely used by the providers. The field contains of a JSON encoded
+Array with each provider as array key. Common properties of such provider array
+could be `active` or `lastUsed`. Since the information is stored in either the
+`be_users` or the `fe_users` table, the context is implicit. Same goes for the
+user, the providers deal with. It's important to have such generic field so
+providers are able to store arbitrary data, TYPO3 does not need to know about.
+
+To retrieve and update the providers data, the already mentioned
+:php:`MfaProviderPropertyManager`, which is automatically passed to all
+necessary provider methods, should be used. It is highly discouraged
+to directly access the `mfa` database field.
+
+.. index:: Backend, Frontend, PHP-API, ext:core
diff --git a/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf b/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf
index f16f2309e49d95056d5b7ee3b49a5b9817fe40b9..7c5bf5491023cec1c9f321be5dbce7d92ce08114 100644
--- a/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf
+++ b/typo3/sysext/core/Resources/Private/Language/locallang_core.xlf
@@ -151,6 +151,9 @@ Do you want to continue WITHOUT saving?</source>
 			<trans-unit id="labels.cancel" resname="labels.cancel">
 				<source>Cancel</source>
 			</trans-unit>
+			<trans-unit id="labels.deactivate" resname="labels.deactivate">
+				<source>Deactivate</source>
+			</trans-unit>
 			<trans-unit id="labels.hidden" resname="labels.hidden">
 				<source>Hidden</source>
 			</trans-unit>
@@ -415,6 +418,18 @@ Do you want to continue WITHOUT saving?</source>
 			<trans-unit id="labels.label.search_levels" resname="labels.label.search_levels">
 				<source>Search levels</source>
 			</trans-unit>
+			<trans-unit id="labels.active" resname="labels.active">
+				<source>Active</source>
+			</trans-unit>
+			<trans-unit id="labels.locked" resname="labels.locked">
+				<source>Locked</source>
+			</trans-unit>
+			<trans-unit id="labels.mfa.enabled" resname="labels.mfa.enabled">
+				<source>MFA enabled</source>
+			</trans-unit>
+			<trans-unit id="labels.mfa.disabled" resname="labels.mfa.disabled">
+				<source>MFA disabled</source>
+			</trans-unit>
 			<trans-unit id="labels.recordReadonly" resname="labels.recordReadonly">
 				<source>The table is defined as readonly and can't be edited.</source>
 			</trans-unit>
@@ -912,6 +927,18 @@ Do you want to refresh it now?</source>
 			<trans-unit id="buttons.recreateSlugExplanation" resname="buttons.recreateSlugExplanation">
 				<source>Recalculate URL segment from page title</source>
 			</trans-unit>
+			<trans-unit id="buttons.deactivateMfa" resname="buttons.deactivateMfa">
+				<source>Deactivate multi-factor authentication</source>
+			</trans-unit>
+			<trans-unit id="buttons.deactivateMfaProvider" resname="buttons.deactivateMfaProvider">
+				<source>Deactivate %s</source>
+			</trans-unit>
+			<trans-unit id="buttons.deactivateMfa.confirmation.text" resname="deactivateMfa.confirmation.text">
+				<source>Are you sure you want to deactivate all active providers? This action can not be undone and will be applied immediately!</source>
+			</trans-unit>
+			<trans-unit id="buttons.deactivateMfaProvider.confirmation.text" resname="deactivateMfaProvider.confirmation.text">
+				<source>Are you sure you want to deactivate %s? This action can not be undone and will be applied immediately!</source>
+			</trans-unit>
 			<trans-unit id="slugCreation.success.page" resname="slugCreation.success.page">
 				<source>This page will be reachable via %s</source>
 			</trans-unit>
diff --git a/typo3/sysext/core/Resources/Private/Language/locallang_mfa_provider.xlf b/typo3/sysext/core/Resources/Private/Language/locallang_mfa_provider.xlf
new file mode 100644
index 0000000000000000000000000000000000000000..1d937ac7fb9c5a6fd793cf7b7a21c9a2bdc5f047
--- /dev/null
+++ b/typo3/sysext/core/Resources/Private/Language/locallang_mfa_provider.xlf
@@ -0,0 +1,157 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+	<file t3:id="1611879242" source-language="en" datatype="plaintext" original="EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf" date="2021-01-29T00:14:02Z" product-name="cms">
+		<header/>
+		<body>
+			<trans-unit id="totp.title" resname="totp.title">
+				<source>Time-based one-time password</source>
+			</trans-unit>
+			<trans-unit id="totp.description" resname="totp.description">
+				<source>This provider allows to authenticate with a single-use passcode which is based on the current time.
+					Each code is only valid for 30 seconds. You need a OTP application supporting such tokens.</source>
+			</trans-unit>
+			<trans-unit id="totp.setupInstructions" resname="totp.setupInstructions">
+				<source>The time-based one-time password provider enables you to strengthen your accounts' security by requiring a six-digit code on every login.
+					This provider is based on a shared secret, which will be exchanged between your OTP application (or device) and TYPO3. Each code takes the current time into account and is only valid for 30 seconds.
+
+					Setting up:
+					1. Scan the QR-code or directly enter the shared secret in your application or device
+					2. Enter the generated six-digit code in the corresponding field
+					3. Add a specific name for this provider (optional)
+					4. Submit the form to activate the provider</source>
+			</trans-unit>
+			<trans-unit id="recoveryCodes.title" resname="recoveryCodes.title">
+				<source>Recovery codes</source>
+			</trans-unit>
+			<trans-unit id="recoveryCodes.description" resname="recoveryCodes.description">
+				<source>This provider allows to authenticate with a set of single-use passcodes, in case you lost your
+					primary MFA credentials, or just have no access to them currently.</source>
+			</trans-unit>
+			<trans-unit id="recoveryCodes.setupInstructions" resname="recoveryCodes.setupInstructions">
+				<source>Recovery codes are random eight-digit codes which can be used to authenticate, in case your lost your main authentication credentials. Each code is only valid once.
+					As soon as all recovery codes are exhausted, you're required to generate a new set. Therefore, periodically check the amount of remaining codes in the Edit / Change view of the provider.
+
+					Setting up:
+					1. Copy the recovery codes and store them at a safe place
+					2. Add a specific name for this provider (optional)
+					3. Submit the form to activate the provider - see below note
+
+					Note: Since the recovery codes are being encrypted and stored securely, this process can take some time.</source>
+			</trans-unit>
+			<trans-unit id="totpInputLabel" resname="totpInputLabel">
+				<source>Enter the six-digit code</source>
+			</trans-unit>
+			<trans-unit id="totpInputHelp" resname="totpInputHelp">
+				<source>This code should now be displayed on your device or in your application.</source>
+			</trans-unit>
+			<trans-unit id="locked" resname="locked">
+				<source>
+					The maximum attempts for this provider are exceeded. Please use another provider, reset your
+					password or contact your administrator.
+				</source>
+			</trans-unit>
+			<trans-unit id="edit.table.head" resname="edit.table.head">
+				<source>Provider information</source>
+			</trans-unit>
+			<trans-unit id="edit.table.name" resname="edit.table.name">
+				<source>Name</source>
+			</trans-unit>
+			<trans-unit id="edit.table.nameInputPlaceholder" resname="edit.table.nameInputPlaceholder">
+				<source>Enter a name</source>
+			</trans-unit>
+			<trans-unit id="edit.table.recoveryCodesLeft" resname="edit.table.recoveryCodesLeft">
+				<source>Recovery codes left</source>
+			</trans-unit>
+			<trans-unit id="edit.table.lastUsed" resname="edit.table.lastUsed">
+				<source>Last used</source>
+			</trans-unit>
+			<trans-unit id="edit.table.lastUsed.default" resname="edit.table.lastUsed.default">
+				<source>Never</source>
+			</trans-unit>
+			<trans-unit id="edit.table.lastUpdated" resname="edit.table.lastUpdated">
+				<source>Last updated</source>
+			</trans-unit>
+			<trans-unit id="edit.regenerate" resname="edit.regenerate">
+				<source>Regenerate recovery codes</source>
+			</trans-unit>
+			<trans-unit id="edit.regenerate.description" resname="edit.regenerate.description">
+				<source>
+					If you lost your recovery codes or just want to get fresh ones, you can
+					regenerate them by selecting the checkbox below. You've currently %d codes left.
+				</source>
+			</trans-unit>
+			<trans-unit id="edit.regenerate.inputLabel" resname="edit.regenerate.inputLabel">
+				<source>Regenerate recovery codes</source>
+			</trans-unit>
+			<trans-unit id="setup.step.1" resname="setup.step.1">
+				<source>Step 1</source>
+			</trans-unit>
+			<trans-unit id="setup.step.1a" resname="setup.step.1a">
+				<source>Step 1a</source>
+			</trans-unit>
+			<trans-unit id="setup.step.1b" resname="setup.step.1b">
+				<source>Step 1b</source>
+			</trans-unit>
+			<trans-unit id="setup.step.2" resname="setup.step.2">
+				<source>Step 2</source>
+			</trans-unit>
+			<trans-unit id="setup.step.3" resname="setup.step.3">
+				<source>Step 3</source>
+			</trans-unit>
+			<trans-unit id="setup.qrCode.label" resname="setup.qrCode.label">
+				<source>Scan the displayed QR code</source>
+			</trans-unit>
+			<trans-unit id="setup.qrCode.help" resname="setup.qrCode.help">
+				<source>Scan this code with your OTP application (e.g. GoogleAuthenticator).</source>
+			</trans-unit>
+			<trans-unit id="setup.secret.label" resname="setup.secret.label">
+				<source>Copy the shared secret</source>
+			</trans-unit>
+			<trans-unit id="setup.secret.help" resname="setup.secret.help">
+				<source>You can also enter the shared secret manually in your OTP device or application.</source>
+			</trans-unit>
+			<trans-unit id="setup.name.label" resname="setup.name.label">
+				<source>Enter a name (optional)</source>
+			</trans-unit>
+			<trans-unit id="setup.name.help" resname="setup.name.help">
+				<source>Specify a custom name for this provider.</source>
+			</trans-unit>
+			<trans-unit id="setup.recoveryCodes.InputLabel" resname="setup.recoveryCodes.InputLabel">
+				<source>Recovery codes</source>
+			</trans-unit>
+			<trans-unit id="setup.recoveryCodes.InputHelp" resname="setup.recoveryCodes.InputHelp">
+				<source>Copy these codes and store them at a safe place.</source>
+			</trans-unit>
+			<trans-unit id="setup.recoveryCodes.reloadTitle" resname="setup.recoveryCodes.reloadTitle">
+				<source>Reload recovery codes</source>
+			</trans-unit>
+			<trans-unit id="setup.recoveryCodes.reloadLabel" resname="setup.recoveryCodes.reloadLabel">
+				<source>Reload</source>
+			</trans-unit>
+			<trans-unit id="setup.recoveryCodes.noActiveProviders.title" resname="setup.recoveryCodes.noActiveProviders.title">
+				<source>Setup not possible</source>
+			</trans-unit>
+			<trans-unit id="setup.recoveryCodes.noActiveProviders.message" resname="setup.recoveryCodes.noActiveProviders.message">
+				<source>
+					Recovery codes are only meant as a fallback if you lose access to your main multi-factor
+					authentication credentials. Therefore, please active a comprehensive MFA provider first.
+				</source>
+			</trans-unit>
+			<trans-unit id="auth.recoveryCodes.inputLabel" resname="auth.recoveryCodes.inputLabel">
+				<source>Enter an eight-digit recovery code</source>
+			</trans-unit>
+			<trans-unit id="unlock.recoveryCodes.title" resname="unlock.recoveryCodes.title">
+				<source>Your recovery codes were automatically updated!</source>
+			</trans-unit>
+			<trans-unit id="unlock.recoveryCodes.message" resname="unlock.recoveryCodes.message">
+				<source>Please copy and store them at a safe place: %s</source>
+			</trans-unit>
+			<trans-unit id="update.recoveryCodes.title" resname="unlock.recoveryCodes.title">
+				<source>Recovery codes successfully regenerated</source>
+			</trans-unit>
+			<trans-unit id="update.recoveryCodes.message" resname="unlock.recoveryCodes.message">
+				<source>Please copy and store them at a safe place: %s</source>
+			</trans-unit>
+		</body>
+	</file>
+</xliff>
diff --git a/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf b/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf
index ec22275a23e6a85dab8445f63c873d0bc243828a..89b2ac2c6040d7929aad308b9f4af80a61b79adb 100644
--- a/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf
+++ b/typo3/sysext/core/Resources/Private/Language/locallang_tca.xlf
@@ -24,6 +24,9 @@
 			<trans-unit id="availableWidgets" resname="userMods">
 				<source>Dashboard widgets</source>
 			</trans-unit>
+			<trans-unit id="mfa_providers" resname="mfa_providers">
+				<source>Allowed multi-factor authentication providers</source>
+			</trans-unit>
 			<trans-unit id="allowed_languages" resname="allowed_languages">
 				<source>Limit to languages</source>
 			</trans-unit>
@@ -54,6 +57,9 @@
 			<trans-unit id="be_users.password" resname="be_users.password">
 				<source>Password</source>
 			</trans-unit>
+			<trans-unit id="be_users.mfa" resname="be_users.mfa">
+				<source>Multi-factor authentication</source>
+			</trans-unit>
 			<trans-unit id="be_users.avatar" resname="be_users.avatar">
 				<source>Avatar</source>
 			</trans-unit>
diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.html
new file mode 100644
index 0000000000000000000000000000000000000000..efd05d28957e9ae303cc767d2c9dfb6bb6590d0e
--- /dev/null
+++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.html
@@ -0,0 +1,26 @@
+<html
+    xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
+    xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
+    data-namespace-typo3-fluid="true">
+
+<f:if condition="!{isLocked}">
+    <f:then>
+        <div class="form-field-item">
+            <div class="form-control-wrap">
+                <div class="form-control-holder">
+                    <div class="form-control-clearable form-control">
+                        <input type="text" id="recoveryCode" class="form-control input-login" name="rc" value="" autofocus="autofocus" required="required" maxlength="8" minlength="8" pattern="[0-9]+" aria-label="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:auth.recoveryCodes.inputLabel')}" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:auth.recoveryCodes.inputLabel')}"/>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </f:then>
+    <f:else>
+        <p>
+            <core:icon identifier="actions-lock"/>
+            <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:locked"/>
+        </p>
+    </f:else>
+</f:if>
+
+</html>
diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..a5caee1df77d6d0de9ea308f1cf1966b4343d21f
--- /dev/null
+++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.html
@@ -0,0 +1,82 @@
+<div class="row">
+    <div class="col-md-5">
+        <div class="table-fit">
+            <table class="table">
+                <thead>
+                    <tr>
+                        <th colspan="2">
+                            <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.head"/>
+                        </th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr>
+                        <td>
+                            <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.name"/>
+                        </td>
+                        <td>
+                            <f:if condition="{name}">
+                                <f:then>
+                                    {name}
+                                </f:then>
+                                <f:else>
+                                    <input type="text" id="name" name="name" value="" class="form-control" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.nameInputPlaceholder')}"/>
+                                </f:else>
+                            </f:if>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>
+                            <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.recoveryCodesLeft"/>
+                        </td>
+                        <td>
+                            {amountOfCodesLeft}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>
+                            <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed"/>
+                        </td>
+                        <td>
+                            <f:if condition="{lastUsed}">
+                                <f:then>{lastUsed}</f:then>
+                                <f:else><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed.default"/></f:else>
+                            </f:if>
+                        </td>
+                    </tr>
+                    <f:if condition="{updated}">
+                        <tr>
+                            <td>
+                                <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUpdated"/>
+                            </td>
+                            <td>
+                                {updated}
+                            </td>
+                        </tr>
+                    </f:if>
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+<div class="row">
+    <div class="col-md-5">
+        <h3><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.regenerate"/></h3>
+        <p><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.regenerate.description" arguments="{0: amountOfCodesLeft}"/></p>
+        <f:comment>
+            TODO: The following should be a button which directly triggers a form submit
+            with the information to regenerate the recovery codes saved in some hidden field.
+        </f:comment>
+        <div class="mb-3">
+            <div class="form-check">
+                <input type="checkbox" name="regenerateCodes" id="regenerateCodes" class="form-check-input" value="1" />
+                <label class="form-check-label" for="regenerateCodes">
+                    <span class="form-check-label-text">
+                        <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.regenerate.inputLabel"/>
+                    </span>
+                </label>
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.html
new file mode 100644
index 0000000000000000000000000000000000000000..54e4d11825246382aa62da4624a27ad861f8445c
--- /dev/null
+++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.html
@@ -0,0 +1,37 @@
+<fieldset class="row">
+    <div class="col-lg-6">
+        <div class="mb-3">
+            <label for="recoveryCodes" class="form-label">
+                <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.1"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.InputLabel"/>
+            </label>
+            <div class=form-field-item">
+            <span id="recoveryCodesHelp" class="text-muted">
+                <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.InputHelp"/>
+            </span>
+                <div class="form-control-wrap mt-1">
+                    <textarea type="text" id="recoveryCodes" name="recoveryCodes" class="form-control" readonly="readonly" style="min-height: 10rem;">{recoveryCodes}</textarea>
+                </div>
+            </div>
+            <button type="button" onclick="window.location.reload();" class="btn btn-default btn-sm" title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.reloadTitle')}">
+                <core:icon identifier="actions-refresh" />
+                <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.reloadLabel"/>
+            </button>
+        </div>
+    </div>
+    <div class="col-lg-6">
+        <div class="mb-3">
+            <label for="name" class="form-label">
+                <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.2"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.label"/>
+            </label>
+            <div class=form-field-item">
+            <span id="nameHelp" class="text-muted">
+                <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.help"/>
+            </span>
+                <div class="form-control-wrap mt-1">
+                    <input type="text" id="name" name="name" value="" class="form-control"/>
+                </div>
+            </div>
+        </div>
+    </div>
+    <input type="hidden" name="checksum" value="{checksum}"/>
+</fieldset>
diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.html
new file mode 100644
index 0000000000000000000000000000000000000000..887f4331069de8df81eb00262afc0bdda0fddca9
--- /dev/null
+++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.html
@@ -0,0 +1,26 @@
+<html
+    xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
+    xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
+    data-namespace-typo3-fluid="true">
+
+<f:if condition="!{isLocked}">
+    <f:then>
+        <div class="form-field-item">
+            <div class="form-control-wrap">
+                <div class="form-control-holder">
+                    <div class="form-control-clearable form-control">
+                        <input type="text" id="totp" class="form-control input-login" name="totp" value="" autofocus="autofocus" required="required" maxlength="6" minlength="6" pattern="[0-9]+" aria-label="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputLabel')}" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputLabel')}"/>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </f:then>
+    <f:else>
+        <p>
+            <core:icon identifier="actions-lock"/>
+            <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:locked"/>
+        </p>
+    </f:else>
+</f:if>
+
+</html>
diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..ff02ad0086ca0925db9396c6d934bf7822800858
--- /dev/null
+++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.html
@@ -0,0 +1,53 @@
+<div class="row">
+    <div class="col-md-5">
+        <div class="table-fit">
+            <table class="table">
+                <thead>
+                <tr>
+                    <th colspan="2">
+                        <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.head"/>
+                    </th>
+                </tr>
+                </thead>
+                <tbody>
+                <tr>
+                    <td>
+                        <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.name"/>
+                    </td>
+                    <td>
+                        <f:if condition="{name}">
+                            <f:then>
+                                {name}
+                            </f:then>
+                            <f:else>
+                                <input type="text" id="name" name="name" value="" class="form-control" placeholder="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.nameInputPlaceholder')}"/>
+                            </f:else>
+                        </f:if>
+                    </td>
+                </tr>
+                <tr>
+                    <td>
+                        <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed"/>
+                    </td>
+                    <td>
+                        <f:if condition="{lastUsed}">
+                            <f:then>{lastUsed}</f:then>
+                            <f:else><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUsed.default"/></f:else>
+                        </f:if>
+                    </td>
+                </tr>
+                <f:if condition="{updated}">
+                    <tr>
+                        <td>
+                            <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:edit.table.lastUpdated"/>
+                        </td>
+                        <td>
+                            {updated}
+                        </td>
+                    </tr>
+                </f:if>
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
diff --git a/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.html b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.html
new file mode 100644
index 0000000000000000000000000000000000000000..1058abae3268d2a106b0ae7fa85f2ff0b901dbd9
--- /dev/null
+++ b/typo3/sysext/core/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.html
@@ -0,0 +1,59 @@
+<fieldset class="row">
+    <div class="col-lg-4">
+        <div class="mt-3">
+            <label for="qr-code" class="form-label">
+                <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.1a"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.qrCode.label"/>
+            </label>
+            <div class="form-field-item">
+            <span id="qrCodeHelp" class="text-muted">
+                <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.qrCode.help"/>
+            </span>
+                <div class="form-control-wrap mt-1">
+                    <div id="qr-code" class="border float-start">{qrCode -> f:format.raw()}</div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="col-lg-8">
+        <div class="mt-3 mb-3">
+            <label for="secret" class="form-label">
+                <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.1b"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.secret.label"/>
+            </label>
+            <div class="form-field-item">
+                <span id="secretHelp" class="text-muted">
+                    <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.secret.help"/>
+                </span>
+                <div class="form-control-wrap mt-1">
+                    <input type="text" id="secret" name="secret" value="{secret}" readonly="readonly" class="form-control"/>
+                </div>
+            </div>
+        </div>
+        <div class="mb-3">
+            <label for="name" class="form-label">
+                <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.2"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.label"/>
+            </label>
+            <div class="form-field-item">
+                <span id="nameHelp" class="text-muted">
+                    <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.name.help"/>
+                </span>
+                <div class="form-control-wrap mt-1">
+                    <input type="text" id="name" name="name" value="" class="form-control"/>
+                </div>
+            </div>
+        </div>
+        <div class="mb-3">
+            <label for="totp" class="form-label">
+                <span class="badge rounded-pill bg-primary"><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.step.3"/></span> <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputLabel"/>
+            </label>
+            <div class="form-field-item">
+                <span id="totpHelp" class="text-muted">
+                    <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totpInputHelp"/>
+                </span>
+                <div class="form-control-wrap mt-1">
+                    <input type="text" id="totp" name="totp" value="" required="required" class="form-control" maxlength="6" minlength="6" pattern="[0-9]+"/>
+                </div>
+            </div>
+        </div>
+        <input type="hidden" name="checksum" value="{checksum}"/>
+    </div>
+</fieldset>
diff --git a/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5ebe09a1b943fb56a4dd6b61b9bb3a0601cf5f2e
--- /dev/null
+++ b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/RecoveryCodesProviderTest.php
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Functional\Authentication\Mfa\Provider;
+
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodesProvider;
+use TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Http\PropagateResponseException;
+use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
+
+class RecoveryCodesProviderTest extends FunctionalTestCase
+{
+    private BackendUserAuthentication $user;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->importDataSet(ORIGINAL_ROOT . 'typo3/sysext/core/Tests/Functional/Fixtures/be_users.xml');
+        $this->user = $this->setUpBackendUser(1);
+        $GLOBALS['LANG'] = LanguageService::createFromUserPreferences($this->user);
+    }
+
+    /**
+     * @test
+     */
+    public function setupFailsIfNoOtherMfaProviderIsActive(): void
+    {
+        $request = new ServerRequest('https://example.com', 'GET');
+        $recoveryCodesManifest = $this->getContainer()->get(MfaProviderRegistry::class)->getProvider('recovery-codes');
+        $propertyManager = MfaProviderPropertyManager::create($recoveryCodesManifest, $this->user);
+        $subject = $this->getContainer()->get(RecoveryCodesProvider::class);
+        $this->expectException(PropagateResponseException::class);
+        $subject->handleRequest($request, $propertyManager, 'setup');
+    }
+
+    /**
+     * @test
+     */
+    public function setupReturnsHtmlWithRecoveryCodes(): void
+    {
+        $this->setupTotp();
+        $request = new ServerRequest('https://example.com', 'GET');
+        $recoveryCodesManifest = $this->getContainer()->get(MfaProviderRegistry::class)->getProvider('recovery-codes');
+        $propertyManager = MfaProviderPropertyManager::create($recoveryCodesManifest, $this->user);
+        $subject = $this->getContainer()->get(RecoveryCodesProvider::class);
+        $response = $subject->handleRequest($request, $propertyManager, 'setup');
+        self::assertStringContainsString('<textarea type="text" id="recoveryCodes"', $response->getBody()->getContents());
+    }
+
+    /**
+     * The user must have another MFA provider configured / activated in order to test recovery codes
+     */
+    protected function setupTotp(): void
+    {
+        $totpProvider = $this->getContainer()->get(MfaProviderRegistry::class)->getProvider('totp');
+        $propertyManager = MfaProviderPropertyManager::create($totpProvider, $this->user);
+        $request = new ServerRequest('https://example.com', 'POST');
+        $secret = 'supersecret';
+        $timestamp = $this->getContainer()->get(Context::class)->getPropertyFromAspect('date', 'timestamp');
+        $totpInstance = new Totp($secret);
+        $totp = $totpInstance->generateTotp((int)floor($timestamp / 30));
+        $request = $request->withQueryParams(['totp' => $totp]);
+        $request = $request->withParsedBody(['secret' => $secret, 'checksum' => GeneralUtility::hmac($secret, 'totp-setup')]);
+        $result = $totpProvider->activate($request, $propertyManager);
+        self::assertTrue($result);
+    }
+}
diff --git a/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/TotpProviderTest.php b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/TotpProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ef4aae11b6f687693048cb7fa52dbc54198573b3
--- /dev/null
+++ b/typo3/sysext/core/Tests/Functional/Authentication/Mfa/Provider/TotpProviderTest.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Functional\Authentication\Mfa\Provider;
+
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+use TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
+
+class TotpProviderTest extends FunctionalTestCase
+{
+    private BackendUserAuthentication $user;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->importDataSet(ORIGINAL_ROOT . 'typo3/sysext/core/Tests/Functional/Fixtures/be_users.xml');
+        $this->user = $this->setUpBackendUser(1);
+        $GLOBALS['LANG'] = LanguageService::createFromUserPreferences($this->user);
+    }
+
+    /**
+     * @test
+     */
+    public function activateReturnsTrue(): void
+    {
+        $subject = GeneralUtility::makeInstance(MfaProviderRegistry::class)->getProvider('totp');
+        $propertyManager = MfaProviderPropertyManager::create($subject, $this->user);
+        $request = new ServerRequest('https://example.com', 'POST');
+        $secret = 'supersecret';
+        $timestamp = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
+        $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret);
+        $totp = $totpInstance->generateTotp((int)floor($timestamp / 30));
+        $request = $request->withQueryParams(['totp' => $totp]);
+        $request = $request->withParsedBody(['secret' => $secret, 'checksum' => GeneralUtility::hmac($secret, 'totp-setup')]);
+        self::assertTrue($subject->activate($request, $propertyManager));
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/RecoveryCodesTest.php b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/RecoveryCodesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ad0355fcd912fddbcbb4d2310fb64e5d44f64f72
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/RecoveryCodesTest.php
@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Unit\Authentication\Mfa\Provider;
+
+use TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodes;
+use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash;
+use TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class RecoveryCodesTest extends UnitTestCase
+{
+    protected RecoveryCodes $subject;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->subject = GeneralUtility::makeInstance(RecoveryCodes::class, 'BE');
+    }
+
+    /**
+     * @test
+     */
+    public function generateRecoveryCodesTest(): void
+    {
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordHashing'] = [
+            'className' => Argon2iPasswordHash::class,
+            'options' => [],
+        ];
+
+        $codes = $this->subject->generateRecoveryCodes();
+
+        self::assertCount(8, $codes);
+
+        $plainCodes = array_keys($codes);
+        $hashedCodes = array_values($codes);
+        $hashInstance = (new Argon2iPasswordHash());
+
+        foreach ($hashedCodes as $key => $code) {
+            self::assertTrue($hashInstance->isValidSaltedPW($code));
+            self::assertTrue($hashInstance->checkPassword((string)$plainCodes[$key], $code));
+        }
+    }
+
+    /**
+     * @test
+     */
+    public function generatePlainRecoveryCodesThrowsExceptionOnInvalidLengthTest(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1613666803);
+        $this->subject->generatePlainRecoveryCodes(6);
+    }
+
+    /**
+     * @test
+     * @dataProvider generatePlainRecoveryCodesTestDataProvider
+     *
+     * @param int $length
+     * @param int $quantity
+     */
+    public function generatePlainRecoveryCodesTest(int $length, int $quantity): void
+    {
+        $recoveryCodes = $this->subject->generatePlainRecoveryCodes($length, $quantity);
+        self::assertCount($quantity, $recoveryCodes);
+        foreach ($recoveryCodes as $code) {
+            self::assertIsNumeric($code);
+            self::assertEquals($length, strlen($code));
+        }
+    }
+
+    public function generatePlainRecoveryCodesTestDataProvider(): \Generator
+    {
+        yield 'Default 8 codes with 8 chars' => [8, 8];
+        yield '8 codes with 10 chars' => [8, 10];
+        yield '10 codes with 8 chars' => [10, 8];
+        yield '0 codes with 8 chars' => [8, 0];
+        yield '10 codes with 10 chars' => [10, 10];
+    }
+
+    /**
+     * @test
+     */
+    public function generatedHashedRecoveryCodesAreHashedWithDefaultHashInstanceTest(): void
+    {
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordHashing'] = [
+            'className' => BcryptPasswordHash::class,
+            'options' => [],
+        ];
+
+        $codes = $this->subject->generatedHashedRecoveryCodes(['12345678', '87654321']);
+
+        self::assertTrue((new BcryptPasswordHash())->isValidSaltedPW(reset($codes)));
+        self::assertCount(2, $codes);
+    }
+
+    /**
+     * @test
+     */
+    public function verifyRecoveryCodeTest(): void
+    {
+        $recoveryCode = '18742989';
+        $codes = [];
+
+        // False on empty codes
+        self::assertFalse($this->subject->verifyRecoveryCode($recoveryCode, $codes));
+
+        $codes = $this->subject->generatedHashedRecoveryCodes(
+            array_merge([$recoveryCode], $this->subject->generatePlainRecoveryCodes(8, 2))
+        );
+
+        // Recovery code can be verified
+        self::assertTrue($this->subject->verifyRecoveryCode($recoveryCode, $codes));
+        // Verified code is removed from available codes
+        self::assertCount(2, $codes);
+        // Recovery code can not be verified again
+        self::assertFalse($this->subject->verifyRecoveryCode($recoveryCode, $codes));
+    }
+
+    /**
+     * @test
+     */
+    public function verifyRecoveryCodeUsesTheCorrectHashInstanceTest(): void
+    {
+        $code = '18742989';
+        $codes = [(new Argon2iPasswordHash())->getHashedPassword($code)];
+
+        // Ensure we have another default hash instance
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordHashing'] = [
+            'className' => BcryptPasswordHash::class,
+            'options' => [],
+        ];
+
+        self::assertTrue($this->subject->verifyRecoveryCode($code, $codes));
+        self::assertEmpty($codes);
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/TotpTest.php b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/TotpTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8e8422cc01c2520ddde312155bdacf5fa46a9506
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Authentication/Mfa/Provider/TotpTest.php
@@ -0,0 +1,199 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Unit\Authentication\Mfa\Provider;
+
+use Base32\Base32;
+use TYPO3\CMS\Core\Authentication\Mfa\Provider\Totp;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\DateTimeAspect;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class TotpTest extends UnitTestCase
+{
+    protected string $secret;
+    protected int $timestamp = 1613652061;
+    protected $resetSingletonInstances = true;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        // We generate the secret here to ensure TOTP works with a secret encoded by the 3rd party package
+        $this->secret = Base32::encode('TYPO3IsAwesome!'); // KRMVATZTJFZUC53FONXW2ZJB
+    }
+
+    /**
+     * @test
+     */
+    public function throwsExceptionOnDisallowedAlogTest(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1611748791);
+        GeneralUtility::makeInstance(Totp::class, 'some-secret', 'md5');
+    }
+
+    /**
+     * @test
+     */
+    public function throwsExceptionOnInvalidTotpLengthTest(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1611748792);
+        GeneralUtility::makeInstance(Totp::class, 'some-secret', 'sha1', 4);
+    }
+
+    /**
+     * @test
+     * @dataProvider totpDataProvider
+     *
+     * @param string $expectedTotp
+     * @param array  $arguments
+     */
+    public function generateTotpTest(string $expectedTotp, array $arguments): void
+    {
+        $counter = (int)floor(($this->timestamp - 0) / 30); // see Totp::getTimeCounter()
+
+        self::assertEquals(
+            $expectedTotp,
+            GeneralUtility::makeInstance(Totp::class, $this->secret, ...$arguments)->generateTotp($counter)
+        );
+    }
+
+    /**
+     * @test
+     * @dataProvider totpDataProvider
+     *
+     * @param string $totp
+     * @param array $arguments
+     */
+    public function verifyTotpTest(string $totp, array $arguments): void
+    {
+        GeneralUtility::makeInstance(Context::class)
+            ->setAspect('date', new DateTimeAspect(new \DateTimeImmutable('@' . $this->timestamp)));
+
+        self::assertTrue(
+            GeneralUtility::makeInstance(Totp::class, $this->secret, ...$arguments)->verifyTotp($totp)
+        );
+    }
+
+    public function totpDataProvider(): \Generator
+    {
+        yield 'Default' => ['337475', []];
+        yield 'sha256 algo' => ['874487', ['sha256']];
+        yield 'sha512 algo' => ['497852', ['sha512']];
+        yield '7 digit code' => ['8337475', ['sha1', 7]];
+        yield '8 digit code' => ['48337475', ['sha1', 8]];
+    }
+
+    /**
+     * @test
+     */
+    public function verifyTotpWithGracePeriodTest(): void
+    {
+        GeneralUtility::makeInstance(Context::class)
+            ->setAspect('date', new DateTimeAspect(new \DateTimeImmutable('@' . $this->timestamp)));
+
+        $totpInstance = GeneralUtility::makeInstance(Totp::class, $this->secret);
+
+        $totpFuture = $totpInstance->generateTotp((int)floor((($this->timestamp + 90) - 0) / 30));
+        self::assertFalse($totpInstance->verifyTotp($totpFuture, 3));
+
+        $totpFuture = $totpInstance->generateTotp((int)floor((($this->timestamp + 60) - 0) / 30));
+        self::assertTrue($totpInstance->verifyTotp($totpFuture, 3));
+
+        $totpFuture = $totpInstance->generateTotp((int)floor((($this->timestamp + 30) - 0) / 30));
+        self::assertTrue($totpInstance->verifyTotp($totpFuture, 3));
+
+        $totpPast = $totpInstance->generateTotp((int)floor((($this->timestamp - 30) - 0) / 30));
+        self::assertTrue($totpInstance->verifyTotp($totpPast, 3));
+
+        $totpPast = $totpInstance->generateTotp((int)floor((($this->timestamp - 60) - 0) / 30));
+        self::assertTrue($totpInstance->verifyTotp($totpPast, 3));
+
+        $totpPast = $totpInstance->generateTotp((int)floor((($this->timestamp - 90) - 0) / 30));
+        self::assertFalse($totpInstance->verifyTotp($totpPast, 3));
+    }
+
+    /**
+     * @test
+     * @dataProvider getTotpAuthUrlTestDataProvider
+     *
+     * @param array $constructorArguments
+     * @param array $methodArguments
+     * @param string $expected
+     */
+    public function getTotpAuthUrlTest(array $constructorArguments, array $methodArguments, string $expected): void
+    {
+        $totp = GeneralUtility::makeInstance(Totp::class, ...$constructorArguments);
+
+        self::assertEquals($expected, $totp->getTotpAuthUrl(...$methodArguments));
+    }
+
+    public function getTotpAuthUrlTestDataProvider(): \Generator
+    {
+        yield 'Default Totp with account and additional params' => [
+            [
+                'N5WGS4ZNOR4XA3ZTFVZWS5DF',
+            ],
+            [
+                'Oli`s awesome site`',
+                'user@typo3.org',
+                [
+                    'foo' => 'bar',
+                    'bar' => [
+                        'baz' => 123
+                    ]
+                ]
+            ],
+            'otpauth://totp/Oli%60s%20awesome%20site%60%3Auser%40typo3.org?secret=N5WGS4ZNOR4XA3ZTFVZWS5DF&issuer=Oli%60s%20awesome%20site%60&foo=bar&bar%5Bbaz%5D=123'
+        ];
+        yield 'Custom Totp settings with account without additional params' => [
+            [
+                'N5WGS4ZNOR4XA3ZTFVZWS5DF',
+                'sha256',
+                8,
+                20,
+                12345
+            ],
+            [
+                'Some other site',
+                'user@typo3.org',
+            ],
+            'otpauth://totp/Some%20other%20site%3Auser%40typo3.org?secret=N5WGS4ZNOR4XA3ZTFVZWS5DF&issuer=Some%20other%20site&algorithm=sha256&period=20&digits=8&epoch=12345'
+        ];
+    }
+
+    /**
+     * @test
+     */
+    public function generateEncodedSecretTest(): void
+    {
+        // Check 100 times WITHOUT additional auth factors
+        for ($i=0; $i<100; $i++) {
+            // Assert correct length and secret only contains allowed alphabet
+            self::assertRegExp('/^[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]{32}$/', Totp::generateEncodedSecret());
+        }
+
+        // Check 100 times WITH additional auth factors
+        for ($i=0; $i<100; $i++) {
+            $authFactors = ['uid' => 5, 'username' => 'non.admin'];
+            // Assert correct length and secret only contains allowed alphabet
+            self::assertRegExp('/^[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]{32}$/', Totp::generateEncodedSecret($authFactors));
+        }
+    }
+}
diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json
index 77e67c6590d3641ea327c49c2dad18f51a87ade4..ae5f12cb3d89bce0bded420f5cf9edfdc329aa27 100644
--- a/typo3/sysext/core/composer.json
+++ b/typo3/sysext/core/composer.json
@@ -26,6 +26,8 @@
 		"ext-pcre": "*",
 		"ext-session": "*",
 		"ext-xml": "*",
+		"bacon/bacon-qr-code": "^2.0",
+		"christian-riesen/base32": "^1.5",
 		"cogpowered/finediff": "~0.3.1",
 		"doctrine/annotations": "^1.11",
 		"doctrine/dbal": "^2.12",
diff --git a/typo3/sysext/core/ext_tables.sql b/typo3/sysext/core/ext_tables.sql
index 208281a5a34d8933b1223ee2cc2db9d7df1252ce..d297ca2d10631d56ee5c5dd9ffe6787133ae1475 100644
--- a/typo3/sysext/core/ext_tables.sql
+++ b/typo3/sysext/core/ext_tables.sql
@@ -13,6 +13,7 @@ CREATE TABLE be_groups (
 	tables_modify text,
 	groupMods text,
 	availableWidgets text,
+	mfa_providers text,
 	file_mountpoints text,
 	file_permissions text,
 	TSconfig text,
@@ -58,6 +59,7 @@ CREATE TABLE be_users (
 	lastlogin int(10) unsigned DEFAULT '0' NOT NULL,
 	workspace_id int(11) DEFAULT '0' NOT NULL,
 	category_perms text,
+	mfa mediumblob,
 	KEY username (username)
 );
 
diff --git a/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php b/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php
index 1f6e4f8d15f55d8f901c9b31cbfc7f6126154b8b..6a601f2c0f7cac19aaf37292b6df4d7408a2c018 100644
--- a/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php
+++ b/typo3/sysext/frontend/Classes/Middleware/BackendUserAuthenticator.php
@@ -22,6 +22,7 @@ use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\RequestHandlerInterface;
 use TYPO3\CMS\Backend\FrontendBackendUserAuthentication;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException;
 use TYPO3\CMS\Core\Core\Bootstrap;
 use TYPO3\CMS\Core\Http\NormalizedParams;
 use TYPO3\CMS\Core\Localization\LanguageService;
@@ -83,7 +84,13 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
     {
         // New backend user object
         $backendUserObject = GeneralUtility::makeInstance(FrontendBackendUserAuthentication::class);
-        $backendUserObject->start();
+        try {
+            $backendUserObject->start();
+        } catch (MfaRequiredException $e) {
+            // Do nothing, as the user is not fully authenticated - has not
+            // passed required multi-factor authentication - via the backend.
+            return null;
+        }
         $backendUserObject->unpack_uc();
         if (!empty($backendUserObject->user['uid'])) {
             $this->setBackendUserAspect($backendUserObject, (int)$backendUserObject->user['workspace_id']);
diff --git a/typo3/sysext/frontend/ext_tables.sql b/typo3/sysext/frontend/ext_tables.sql
index 972466189f452f57ecc6db6ee5fcad84d652ec04..9ef075050edf576ea93f40469a66762c677627a6 100644
--- a/typo3/sysext/frontend/ext_tables.sql
+++ b/typo3/sysext/frontend/ext_tables.sql
@@ -62,6 +62,7 @@ CREATE TABLE fe_users (
 	TSconfig text,
 	lastlogin int(10) unsigned DEFAULT '0' NOT NULL,
 	is_online int(10) unsigned DEFAULT '0' NOT NULL,
+	mfa mediumblob,
 
 	KEY parent (pid,username(100)),
 	KEY username (username(100)),
diff --git a/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/MfaProvidersProvider.php b/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/MfaProvidersProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..799b56ed40b4c435de4bd49c553399dd19d7a346
--- /dev/null
+++ b/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/MfaProvidersProvider.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Lowlevel\ConfigurationModuleProvider;
+
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
+
+class MfaProvidersProvider extends AbstractProvider
+{
+    protected MfaProviderRegistry $mfaProviderRegistry;
+
+    public function __construct(MfaProviderRegistry $mfaProviderRegistry)
+    {
+        $this->mfaProviderRegistry = $mfaProviderRegistry;
+    }
+
+    public function getConfiguration(): array
+    {
+        $providers = $this->mfaProviderRegistry->getProviders();
+        $configuration = [];
+        foreach ($providers as $identifier => $provider) {
+            $configuration[$identifier] = [
+                'title' => $this->getLanguageService()->sL($provider->getTitle()),
+                'description' => $this->getLanguageService()->sL($provider->getDescription()),
+                'isDefaultAllowed' => $provider->isDefaultProviderAllowed()
+            ];
+        }
+        return $configuration;
+    }
+}
diff --git a/typo3/sysext/lowlevel/Configuration/Services.yaml b/typo3/sysext/lowlevel/Configuration/Services.yaml
index 412eb4fcb04939fe77a69693e4bca0d15e35e6c5..bf1c37a1d9d8f8e283d4a4fd6512bab02a92fff9 100644
--- a/typo3/sysext/lowlevel/Configuration/Services.yaml
+++ b/typo3/sysext/lowlevel/Configuration/Services.yaml
@@ -199,3 +199,11 @@ services:
         identifier: 'eventListeners'
         label: 'LLL:EXT:lowlevel/Resources/Private/Language/locallang.xlf:eventListeners'
         after: 'siteConfiguration'
+
+  lowlevel.configuration.module.provider.mfaproviders:
+    class: 'TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\MfaProvidersProvider'
+    tags:
+      - name: 'lowlevel.configuration.module.provider'
+        identifier: 'mfaProviders'
+        label: 'LLL:EXT:lowlevel/Resources/Private/Language/locallang.xlf:mfaProviders'
+        after: 'eventListeners'
diff --git a/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf b/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf
index 5eeaf247519de8965376c032fc6e194b9694da97..442fb52eb8d6ded7b36283096d86e16b0b992617 100644
--- a/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf
+++ b/typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf
@@ -39,6 +39,9 @@
 			<trans-unit id="eventListeners" resname="eventListeners">
 				<source>Event Listeners (PSR-14)</source>
 			</trans-unit>
+			<trans-unit id="mfaProviders" resname="mfaProviders">
+				<source>MFA providers</source>
+			</trans-unit>
 			<trans-unit id="formYamlConfiguration" resname="formYamlConfiguration">
 				<source>Form: YAML Configuration</source>
 			</trans-unit>
diff --git a/typo3/sysext/setup/Classes/Controller/SetupModuleController.php b/typo3/sysext/setup/Classes/Controller/SetupModuleController.php
index 8ab67c2453bb56a4c82e211d53934cf9101c761f..f26bf18a0201d6834ee14c224423c3f9864e193b 100644
--- a/typo3/sysext/setup/Classes/Controller/SetupModuleController.php
+++ b/typo3/sysext/setup/Classes/Controller/SetupModuleController.php
@@ -24,6 +24,7 @@ use TYPO3\CMS\Backend\Routing\UriBuilder;
 use TYPO3\CMS\Backend\Template\ModuleTemplate;
 use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
@@ -151,14 +152,17 @@ class SetupModuleController
      */
     protected $eventDispatcher;
 
+    protected MfaProviderRegistry $mfaProviderRegistry;
+
     /**
      * Instantiate the form protection before a simulated user is initialized.
      *
      * @param EventDispatcherInterface $eventDispatcher
      */
-    public function __construct(EventDispatcherInterface $eventDispatcher)
+    public function __construct(EventDispatcherInterface $eventDispatcher, MfaProviderRegistry $mfaProviderRegistry)
     {
         $this->eventDispatcher = $eventDispatcher;
+        $this->mfaProviderRegistry = $mfaProviderRegistry;
         $this->moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class);
         $this->formProtection = FormProtectionFactory::get();
         $pageRenderer = $this->moduleTemplate->getPageRenderer();
@@ -656,6 +660,27 @@ class SetupModuleController
                             . '>' . $iconFactory->getIcon('actions-insert-record', Icon::SIZE_SMALL)
                             . '</button></div>';
                     break;
+                case 'mfa':
+                    $html = '';
+                    $lang = $this->getLanguageService();
+                    $hasActiveProviders = $this->mfaProviderRegistry->hasActiveProviders($backendUser);
+                    if ($hasActiveProviders) {
+                        if ($this->mfaProviderRegistry->hasLockedProviders($backendUser)) {
+                            $html .= ' <span class="badge badge-danger">' . htmlspecialchars($lang->getLL('mfaProviders.lockedMfaProviders')) . '</span>';
+                        } else {
+                            $html .= ' <span class="badge badge-success">' . htmlspecialchars($lang->getLL('mfaProviders.enabled')) . '</span>';
+                        }
+                    }
+                    $html .= '<p class="text-muted">' . nl2br(htmlspecialchars($lang->getLL('mfaProviders.description'))) . '</p>';
+                    if (!$this->mfaProviderRegistry->hasProviders()) {
+                        $html .= '<span class="badge badge-danger">' . htmlspecialchars($lang->getLL('mfaProviders.notAvailable')) . '</span>';
+                        break;
+                    }
+                    $html .= '<a href="' . htmlspecialchars((string)$uriBuilder->buildUriFromRoute('mfa')) . '" class="btn btn-' . ($hasActiveProviders ? 'default' : 'success') . '">';
+                    $html .=    GeneralUtility::makeInstance(IconFactory::class)->getIcon($hasActiveProviders ? 'actions-cog' : 'actions-add', Icon::SIZE_SMALL);
+                    $html .=    ' <span>' . htmlspecialchars($lang->getLL('mfaProviders.' . ($hasActiveProviders ? 'manageLinkTitle' : 'setupLinkTitle'))) . '</span>';
+                    $html .= '</a>';
+                    break;
                 default:
                     $html = '';
             }
diff --git a/typo3/sysext/setup/Resources/Private/Language/locallang.xlf b/typo3/sysext/setup/Resources/Private/Language/locallang.xlf
index f33d2cb2d2a56d44569590ba8858d74265373cb5..a1f9e117c6d3b85a9e523d02fe7aaa707abb7830 100644
--- a/typo3/sysext/setup/Resources/Private/Language/locallang.xlf
+++ b/typo3/sysext/setup/Resources/Private/Language/locallang.xlf
@@ -318,8 +318,29 @@
 			<trans-unit id="passwordCurrent" resname="passwordCurrent">
 				<source>Current password</source>
 			</trans-unit>
-			<trans-unit id="passwordHeader" resname="passwordHeader">
-				<source>Password</source>
+			<trans-unit id="accountSecurity" resname="accountSecurity">
+				<source>Account security</source>
+			</trans-unit>
+			<trans-unit id="mfaProviders" resname="mfaProviders">
+				<source>Multi-factor authentication providers</source>
+			</trans-unit>
+			<trans-unit id="mfaProviders.notAvailable" resname="mfaProviders.notAvailable">
+				<source>Currently no multi-factor authentication providers are available.</source>
+			</trans-unit>
+			<trans-unit id="mfaProviders.description" resname="mfaProviders.description">
+				<source>Use multi-factor authentication to secure your account by providing another factor next to your password.</source>
+			</trans-unit>
+			<trans-unit id="mfaProviders.lockedMfaProviders" resname="mfaProviders.lockedMfaProviders">
+				<source>Some providers are currently locked!</source>
+			</trans-unit>
+			<trans-unit id="mfaProviders.enabled" resname="mfaProviders.enabled">
+				<source>Enabled</source>
+			</trans-unit>
+			<trans-unit id="mfaProviders.setupLinkTitle" resname="mfaProviders.setupLinkTitle">
+				<source>Setup multi-factor authentication</source>
+			</trans-unit>
+			<trans-unit id="mfaProviders.manageLinkTitle" resname="mfaProviders.manageLinkTitle">
+				<source>Manage your MFA providers</source>
 			</trans-unit>
 			<trans-unit id="setupWasUpdated" resname="setupWasUpdated">
 				<source>User settings were updated.</source>
diff --git a/typo3/sysext/setup/ext_tables.php b/typo3/sysext/setup/ext_tables.php
index 028983e59802158788c2a899e094849bb873e281..0edd5ceb0e313f8e1beb5927df887b975805c9fc 100644
--- a/typo3/sysext/setup/ext_tables.php
+++ b/typo3/sysext/setup/ext_tables.php
@@ -121,9 +121,13 @@ $GLOBALS['TYPO3_USER_SETTINGS'] = [
             'label' => 'LLL:EXT:setup/Resources/Private/Language/locallang.xlf:flexibleTextareas_MaxHeight',
             'csh' => 'flexibleTextareas_MaxHeight'
         ],
+        'mfaProviders' => [
+            'type' => 'mfa',
+            'label' => 'LLL:EXT:setup/Resources/Private/Language/locallang.xlf:mfaProviders',
+        ]
     ],
     'showitem' => '--div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:personal_data,realName,email,emailMeAtLogin,avatar,lang,
-            --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:passwordHeader,passwordCurrent,password,password2,
+            --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:accountSecurity,passwordCurrent,password,password2,mfaProviders,
             --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:opening,startModule,
             --div--;LLL:EXT:setup/Resources/Private/Language/locallang.xlf:editFunctionsTab,edit_RTE,resizeTextareas_MaxHeight,titleLen,edit_docModuleUpload,showHiddenFilesAndFolders,copyLevels,resetConfiguration'
 ];