From 9cb30fb9c03e3efb37a0e44d72b24f1bb4814959 Mon Sep 17 00:00:00 2001
From: Oliver Hader <oliver@typo3.org>
Date: Tue, 5 Apr 2022 17:37:33 +0200
Subject: [PATCH] [!!!][FEATURE] Introduce CSRF-like request-token handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A CSRF-like request-token handling has been introduced, to
mitigate potential cross-site requests on actions with side-effects.
This approach does not require an existing server-side user session,
but uses a nonce (number used once) as a "pre-session". The main scope
is to ensure a user actually has visited a page, before submitting
data to the web server.

Introduces package https://packagist.org/packages/firebase/php-jwt
> composer req firebase/php-jwt

Besides that, AbstractUserAuthentication has been changed to require
this introduced request-token, to mitigate Login CSRF attacks.

The security enhancement potentially break custom login templates
and application handlers - this is why it is introduced and enforced
for TYPO3 v12.0.

Resolves: #97305
Releases: main
Change-Id: I74d9e1890017aae4a00999f549ea04716d68f721
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/74183
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Reviewed-by: Torben Hansen <derhansen@gmail.com>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Torben Hansen <derhansen@gmail.com>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
---
 composer.json                                 |   1 +
 composer.lock                                 |  72 +++++++-
 .../Classes/Controller/LoginController.php    |  10 ++
 .../Configuration/RequestMiddlewares.php      |   9 +
 .../Resources/Private/Layouts/Login.html      |   1 +
 .../AbstractUserAuthentication.php            |  19 ++
 .../core/Classes/Context/SecurityAspect.php   | 107 +++++++++++
 .../Middleware/RequestTokenMiddleware.php     | 163 +++++++++++++++++
 .../sysext/core/Classes/Security/JwtTrait.php | 108 +++++++++++
 typo3/sysext/core/Classes/Security/Nonce.php  |  90 ++++++++++
 .../core/Classes/Security/NonceException.php  |  27 +++
 .../core/Classes/Security/NoncePool.php       | 168 ++++++++++++++++++
 .../core/Classes/Security/RequestToken.php    | 120 +++++++++++++
 .../Security/RequestTokenException.php        |  27 +++
 .../Classes/Security/SecretIdentifier.php     |  60 +++++++
 .../Security/SigningProviderInterface.php     |  41 +++++
 .../Security/SigningSecretInterface.php       |  36 ++++
 .../Security/SigningSecretResolver.php        |  73 ++++++++
 ...ing-97305-IntroduceCSRF-likeLoginToken.rst |  74 ++++++++
 ...ntroduceCSRF-likeRequest-tokenHandling.rst | 155 ++++++++++++++++
 .../Tests/Unit/Context/SecurityAspectTest.php |  60 +++++++
 .../Tests/Unit/Security/NoncePoolTest.php     | 135 ++++++++++++++
 .../core/Tests/Unit/Security/NonceTest.php    |  89 ++++++++++
 .../Tests/Unit/Security/RequestTokenTest.php  | 131 ++++++++++++++
 typo3/sysext/core/composer.json               |   1 +
 .../Classes/Controller/LoginController.php    |   2 +
 .../Private/Templates/Login/Login.html        |   4 +-
 .../Classes/ViewHelpers/FormViewHelper.php    |  50 ++++++
 .../Configuration/RequestMiddlewares.php      |  10 ++
 .../FrontendUserAuthenticationTest.php        |  12 +-
 30 files changed, 1846 insertions(+), 9 deletions(-)
 create mode 100644 typo3/sysext/core/Classes/Context/SecurityAspect.php
 create mode 100644 typo3/sysext/core/Classes/Middleware/RequestTokenMiddleware.php
 create mode 100644 typo3/sysext/core/Classes/Security/JwtTrait.php
 create mode 100644 typo3/sysext/core/Classes/Security/Nonce.php
 create mode 100644 typo3/sysext/core/Classes/Security/NonceException.php
 create mode 100644 typo3/sysext/core/Classes/Security/NoncePool.php
 create mode 100644 typo3/sysext/core/Classes/Security/RequestToken.php
 create mode 100644 typo3/sysext/core/Classes/Security/RequestTokenException.php
 create mode 100644 typo3/sysext/core/Classes/Security/SecretIdentifier.php
 create mode 100644 typo3/sysext/core/Classes/Security/SigningProviderInterface.php
 create mode 100644 typo3/sysext/core/Classes/Security/SigningSecretInterface.php
 create mode 100644 typo3/sysext/core/Classes/Security/SigningSecretResolver.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst
 create mode 100644 typo3/sysext/core/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst
 create mode 100644 typo3/sysext/core/Tests/Unit/Context/SecurityAspectTest.php
 create mode 100644 typo3/sysext/core/Tests/Unit/Security/NoncePoolTest.php
 create mode 100644 typo3/sysext/core/Tests/Unit/Security/NonceTest.php
 create mode 100644 typo3/sysext/core/Tests/Unit/Security/RequestTokenTest.php

diff --git a/composer.json b/composer.json
index 13d2e063c2bb..8edc7d47fd9f 100644
--- a/composer.json
+++ b/composer.json
@@ -55,6 +55,7 @@
 		"doctrine/lexer": "^1.2.3",
 		"egulias/email-validator": "^3.2.1",
 		"enshrined/svg-sanitize": "^0.15.4",
+		"firebase/php-jwt": "^6.3",
 		"guzzlehttp/guzzle": "^7.4.5",
 		"guzzlehttp/promises": "^1.4.0",
 		"guzzlehttp/psr7": "^1.8.5 || ^2.1.2",
diff --git a/composer.lock b/composer.lock
index 2952a3f6dd02..95c408262883 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "51ec4a5a4db76370664064eb7ef9751c",
+    "content-hash": "e2f2f173c9b9b8c7168c220e20812786",
     "packages": [
         {
             "name": "bacon/bacon-qr-code",
@@ -766,6 +766,68 @@
             },
             "time": "2022-02-21T09:13:59+00:00"
         },
+        {
+            "name": "firebase/php-jwt",
+            "version": "v6.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/firebase/php-jwt.git",
+                "reference": "018dfc4e1da92ad8a1b90adc4893f476a3b41cb8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/firebase/php-jwt/zipball/018dfc4e1da92ad8a1b90adc4893f476a3b41cb8",
+                "reference": "018dfc4e1da92ad8a1b90adc4893f476a3b41cb8",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1||^8.0"
+            },
+            "require-dev": {
+                "guzzlehttp/guzzle": "^6.5||^7.4",
+                "phpspec/prophecy-phpunit": "^1.1",
+                "phpunit/phpunit": "^7.5||^9.5",
+                "psr/cache": "^1.0||^2.0",
+                "psr/http-client": "^1.0",
+                "psr/http-factory": "^1.0"
+            },
+            "suggest": {
+                "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Firebase\\JWT\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Neuman Vong",
+                    "email": "neuman+pear@twilio.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Anant Narayanan",
+                    "email": "anant@php.net",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
+            "homepage": "https://github.com/firebase/php-jwt",
+            "keywords": [
+                "jwt",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/firebase/php-jwt/issues",
+                "source": "https://github.com/firebase/php-jwt/tree/v6.3.0"
+            },
+            "time": "2022-07-15T16:48:45+00:00"
+        },
         {
             "name": "guzzlehttp/guzzle",
             "version": "7.4.5",
@@ -8344,12 +8406,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/TYPO3/styleguide.git",
-                "reference": "cae579350467f1d60bd0418f59e5459f1559b598"
+                "reference": "5759503933c13dd5ea13c535ac0c026b3a2a0281"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/TYPO3/styleguide/zipball/cae579350467f1d60bd0418f59e5459f1559b598",
-                "reference": "cae579350467f1d60bd0418f59e5459f1559b598",
+                "url": "https://api.github.com/repos/TYPO3/styleguide/zipball/5759503933c13dd5ea13c535ac0c026b3a2a0281",
+                "reference": "5759503933c13dd5ea13c535ac0c026b3a2a0281",
                 "shasum": ""
             },
             "require-dev": {
@@ -8408,7 +8470,7 @@
                 "issues": "https://github.com/TYPO3/styleguide/issues",
                 "source": "https://github.com/TYPO3/styleguide/tree/main"
             },
-            "time": "2022-09-13T17:10:27+00:00"
+            "time": "2022-09-26T09:45:10+00:00"
         },
         {
             "name": "typo3/testing-framework",
diff --git a/typo3/sysext/backend/Classes/Controller/LoginController.php b/typo3/sysext/backend/Classes/Controller/LoginController.php
index b4a0c0b2976f..ebb30f1ca505 100644
--- a/typo3/sysext/backend/Classes/Controller/LoginController.php
+++ b/typo3/sysext/backend/Classes/Controller/LoginController.php
@@ -33,6 +33,7 @@ use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
 use TYPO3\CMS\Core\Configuration\Features;
 use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\SecurityAspect;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
 use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
@@ -44,6 +45,7 @@ use TYPO3\CMS\Core\Localization\LanguageService;
 use TYPO3\CMS\Core\Localization\Locales;
 use TYPO3\CMS\Core\Page\PageRenderer;
 use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
+use TYPO3\CMS\Core\Security\RequestToken;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Fluid\View\StandaloneView;
 
@@ -295,6 +297,8 @@ class LoginController
             'hasLoginError' => $this->isLoginInProgress($request),
             'action' => $action,
             'formActionUrl' => $formActionUrl,
+            'requestTokenName' => RequestToken::PARAM_NAME,
+            'requestTokenValue' => $this->provideRequestTokenJwt(),
             'forgetPasswordUrl' => $this->uriBuilder->buildUriWithRedirect(
                 'password_forget',
                 ['loginProvider' => $this->loginProviderIdentifier],
@@ -425,6 +429,12 @@ class LoginController
         throw new PropagateResponseException(new RedirectResponse($this->redirectToURL, 303), 1607271511);
     }
 
+    protected function provideRequestTokenJwt(): string
+    {
+        $nonce = SecurityAspect::provideIn($this->context)->provideNonce();
+        return RequestToken::create('core/user-auth/be')->toHashSignedJwt($nonce);
+    }
+
     protected function getLanguageService(): LanguageService
     {
         return $GLOBALS['LANG'];
diff --git a/typo3/sysext/backend/Configuration/RequestMiddlewares.php b/typo3/sysext/backend/Configuration/RequestMiddlewares.php
index 4e11b55e9b3b..46cf14492917 100644
--- a/typo3/sysext/backend/Configuration/RequestMiddlewares.php
+++ b/typo3/sysext/backend/Configuration/RequestMiddlewares.php
@@ -41,6 +41,15 @@ return [
                 'typo3/cms-backend/https-redirector',
             ],
         ],
+        'typo3/cms-core/request-token-middleware' => [
+            'target' => \TYPO3\CMS\Core\Middleware\RequestTokenMiddleware::class,
+            'after' => [
+                'typo3/cms-backend/backend-routing',
+            ],
+            'before' => [
+                'typo3/cms-backend/authentication',
+            ],
+        ],
         'typo3/cms-backend/authentication' => [
             'target' => \TYPO3\CMS\Backend\Middleware\BackendUserAuthenticator::class,
             'after' => [
diff --git a/typo3/sysext/backend/Resources/Private/Layouts/Login.html b/typo3/sysext/backend/Resources/Private/Layouts/Login.html
index 0dc6ffc6dac0..7934d9d4d0a7 100644
--- a/typo3/sysext/backend/Resources/Private/Layouts/Login.html
+++ b/typo3/sysext/backend/Resources/Private/Layouts/Login.html
@@ -48,6 +48,7 @@
                                         <input type="hidden" name="userident" id="t3-field-userident" class="t3js-login-userident-field" value="" />
                                         <input type="hidden" name="redirect_url" value="{redirectUrl}" />
                                         <input type="hidden" name="loginRefresh" value="{loginRefresh}" />
+                                        <input type="hidden" name="{requestTokenName}" value="{requestTokenValue}" />
 
                                         <f:render section="loginFormFields" />
 
diff --git a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php
index 70bb33388783..d988351ac104 100644
--- a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php
+++ b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php
@@ -22,6 +22,8 @@ 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\Context\Context;
+use TYPO3\CMS\Core\Context\SecurityAspect;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Database\Connection;
 use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -34,6 +36,7 @@ use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
 use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
 use TYPO3\CMS\Core\Exception;
 use TYPO3\CMS\Core\Http\CookieHeaderTrait;
+use TYPO3\CMS\Core\Security\RequestToken;
 use TYPO3\CMS\Core\Session\UserSession;
 use TYPO3\CMS\Core\Session\UserSessionManager;
 use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
@@ -494,6 +497,22 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
             $this->logger->debug('No user session found');
         }
 
+        if ($activeLogin) {
+            $context = GeneralUtility::makeInstance(Context::class);
+            $securityAspect = SecurityAspect::provideIn($context);
+            $requestToken = $securityAspect->getReceivedRequestToken();
+            $requestTokenScopeMatches = ($requestToken->scope ?? null) === 'core/user-auth/' . strtolower($this->loginType);
+            if (!$requestTokenScopeMatches) {
+                $this->logger->debug('Missing or invalid request token during login', ['requestToken' => $requestToken]);
+                // important: disable `$activeLogin` state
+                $activeLogin = false;
+            } elseif ($requestToken instanceof RequestToken && $requestToken->getSigningSecretIdentifier() !== null) {
+                $securityAspect->getSigningSecretResolver()->revokeIdentifier(
+                    $requestToken->getSigningSecretIdentifier()
+                );
+            }
+        }
+
         // Fetch users from the database (or somewhere else)
         $possibleUsers = $this->fetchPossibleUsers($loginData, $activeLogin, $isExistingSession, $authenticatedUserFromSession);
 
diff --git a/typo3/sysext/core/Classes/Context/SecurityAspect.php b/typo3/sysext/core/Classes/Context/SecurityAspect.php
new file mode 100644
index 000000000000..aa10a59c9339
--- /dev/null
+++ b/typo3/sysext/core/Classes/Context/SecurityAspect.php
@@ -0,0 +1,107 @@
+<?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\Context;
+
+use TYPO3\CMS\Core\Security\Nonce;
+use TYPO3\CMS\Core\Security\NoncePool;
+use TYPO3\CMS\Core\Security\RequestToken;
+use TYPO3\CMS\Core\Security\SigningSecretResolver;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * @internal
+ */
+class SecurityAspect implements AspectInterface
+{
+    /**
+     * `null` in case no request taken was received
+     * `false` in case a request token was received, which was invalid
+     */
+    protected RequestToken|false|null $receivedRequestToken = null;
+
+    protected SigningSecretResolver $signingSecretResolver;
+
+    protected NoncePool $noncePool;
+
+    public static function provideIn(Context $context): self
+    {
+        if ($context->hasAspect('security')) {
+            $securityAspect = $context->getAspect('security');
+        }
+        if (!isset($securityAspect) || !$securityAspect instanceof SecurityAspect) {
+            $securityAspect = GeneralUtility::makeInstance(SecurityAspect::class);
+            $context->setAspect('security', $securityAspect);
+        }
+        return $securityAspect;
+    }
+
+    public function __construct()
+    {
+        $this->noncePool = GeneralUtility::makeInstance(NoncePool::class);
+        $this->signingSecretResolver = GeneralUtility::makeInstance(
+            SigningSecretResolver::class,
+            [
+                'nonce' => $this->noncePool,
+                // @todo enrich in separate step with `*FormProtection`
+            ]
+        );
+    }
+
+    public function get(string $name): null|bool|Nonce|RequestToken
+    {
+        return match ($name) {
+            'receivedRequestToken' => $this->receivedRequestToken,
+            'signingSecretResolver' => $this->signingSecretResolver,
+            'noncePool' => $this->noncePool,
+            default => null,
+        };
+    }
+
+    public function getReceivedRequestToken(): RequestToken|false|null
+    {
+        return $this->receivedRequestToken;
+    }
+
+    public function setReceivedRequestToken(RequestToken|false|null $receivedRequestToken): void
+    {
+        $this->receivedRequestToken = $receivedRequestToken;
+    }
+
+    /**
+     * Resolves corresponding signing secret providers (such as `NoncePool`).
+     * Example: `...->getSigningSecretResolver->findByType('nonce')` resolves `NoncePool`
+     */
+    public function getSigningSecretResolver(): SigningSecretResolver
+    {
+        return $this->signingSecretResolver;
+    }
+
+    public function getNoncePool(): NoncePool
+    {
+        return $this->noncePool;
+    }
+
+    /**
+     * Shortcut function to `NoncePool`, providing a `SigningSecret`
+     * @todo this is a "comfort function", might be dropped
+     */
+    public function provideNonce(): Nonce
+    {
+        return $this->noncePool->provideSigningSecret();
+    }
+}
diff --git a/typo3/sysext/core/Classes/Middleware/RequestTokenMiddleware.php b/typo3/sysext/core/Classes/Middleware/RequestTokenMiddleware.php
new file mode 100644
index 000000000000..49d9546c6497
--- /dev/null
+++ b/typo3/sysext/core/Classes/Middleware/RequestTokenMiddleware.php
@@ -0,0 +1,163 @@
+<?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\Middleware;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Symfony\Component\HttpFoundation\Cookie;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\SecurityAspect;
+use TYPO3\CMS\Core\Http\NormalizedParams;
+use TYPO3\CMS\Core\Security\Nonce;
+use TYPO3\CMS\Core\Security\NonceException;
+use TYPO3\CMS\Core\Security\NoncePool;
+use TYPO3\CMS\Core\Security\RequestToken;
+use TYPO3\CMS\Core\Security\RequestTokenException;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * @internal
+ */
+class RequestTokenMiddleware implements MiddlewareInterface, LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    protected const COOKIE_PREFIX = 'typo3nonce_';
+    protected const SECURE_PREFIX = '__Secure-';
+
+    protected const ALLOWED_METHODS = ['POST', 'PUT', 'PATCH'];
+
+    protected SecurityAspect $securityAspect;
+    protected NoncePool $noncePool;
+
+    public function __construct(Context $context)
+    {
+        $this->securityAspect = SecurityAspect::provideIn($context);
+        $this->noncePool = $this->securityAspect->getNoncePool();
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        // @todo someâ„¢ route handling mechanism might verify request-tokens (-> e.g. backend-routes, unsure for frontend)
+        $this->noncePool->merge($this->resolveNoncePool($request))->purge();
+
+        try {
+            $this->securityAspect->setReceivedRequestToken($this->resolveReceivedRequestToken($request));
+        } catch (RequestTokenException $exception) {
+            // request token was given, but could not be verified
+            $this->securityAspect->setReceivedRequestToken(false);
+            $this->logger->debug('Could not resolve request token', ['exception' => $exception]);
+        }
+
+        $response = $handler->handle($request);
+        return $this->enrichResponseWithCookie($request, $response);
+    }
+
+    protected function resolveNoncePool(ServerRequestInterface $request): NoncePool
+    {
+        $secure = $this->isHttps($request);
+        // resolves cookie name dependent on whether TLS is used in request and uses `__Secure-` prefix,
+        // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes
+        $securePrefix = $secure ? self::SECURE_PREFIX : '';
+        $cookiePrefix = $securePrefix . self::COOKIE_PREFIX;
+        $cookiePrefixLength = strlen($cookiePrefix);
+        $cookies = array_filter(
+            $request->getCookieParams(),
+            static fn ($name) => str_starts_with($name, $cookiePrefix),
+            ARRAY_FILTER_USE_KEY
+        );
+        $items = [];
+        foreach ($cookies as $name => $value) {
+            $name = substr($name, $cookiePrefixLength);
+            try {
+                $items[$name] = Nonce::fromHashSignedJwt($value);
+            } catch (NonceException $exception) {
+                $this->logger->debug('Could not resolve received nonce', ['exception' => $exception]);
+                $items[$name] = null;
+            }
+        }
+        // @todo pool `$options` should be configurable via `$TYPO3_CONF_VARS`
+        return GeneralUtility::makeInstance(NoncePool::class, $items);
+    }
+
+    /**
+     * @throws RequestTokenException
+     */
+    protected function resolveReceivedRequestToken(ServerRequestInterface $request): ?RequestToken
+    {
+        $headerValue = $request->getHeaderLine(RequestToken::HEADER_NAME);
+        $paramValue = (string)($request->getParsedBody()[RequestToken::PARAM_NAME] ?? '');
+        if ($headerValue !== '') {
+            $tokenValue = $headerValue;
+        } elseif (in_array($request->getMethod(), self::ALLOWED_METHODS, true)) {
+            $tokenValue = $paramValue;
+        } else {
+            $tokenValue = '';
+        }
+        if ($tokenValue === '') {
+            return null;
+        }
+        return RequestToken::fromHashSignedJwt($tokenValue, $this->securityAspect->getSigningSecretResolver());
+    }
+
+    protected function enrichResponseWithCookie(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $secure = $this->isHttps($request);
+        $normalizedParams = $request->getAttribute('normalizedParams');
+        $path = $normalizedParams->getSitePath();
+        $securePrefix = $secure ? self::SECURE_PREFIX : '';
+        $cookiePrefix = $securePrefix . self::COOKIE_PREFIX;
+
+        $createCookie = static fn (string $name, string $value, int $expire): Cookie => new Cookie(
+            $name,
+            $value,
+            $expire,
+            $path,
+            null,
+            $secure,
+            true,
+            false,
+            Cookie::SAMESITE_STRICT
+        );
+
+        $cookies = [];
+        // emit new nonce cookies
+        foreach ($this->noncePool->getEmittableNonces() as $name => $nonce) {
+            $cookies[] = $createCookie($cookiePrefix . $name, $nonce->toHashSignedJwt(), 0);
+        }
+        // revoke nonce cookies (exceeded pool size, expired or explicitly revoked)
+        foreach ($this->noncePool->getRevocableNames() as $name) {
+            $cookies[] = $createCookie($cookiePrefix . $name, '', -1);
+        }
+        // finally apply to response
+        foreach ($cookies as $cookie) {
+            $response = $response->withAddedHeader('Set-Cookie', (string)$cookie);
+        }
+        return $response;
+    }
+
+    protected function isHttps(ServerRequestInterface $request): bool
+    {
+        $normalizedParams = $request->getAttribute('normalizedParams');
+        return $normalizedParams instanceof NormalizedParams && $normalizedParams->isHttps();
+    }
+}
diff --git a/typo3/sysext/core/Classes/Security/JwtTrait.php b/typo3/sysext/core/Classes/Security/JwtTrait.php
new file mode 100644
index 000000000000..f7dade703f6b
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/JwtTrait.php
@@ -0,0 +1,108 @@
+<?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\Security;
+
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+
+/**
+ * Trait providing support for JWT using symmetric hash signing.
+ *
+ * The benefit of using a trait in this particular case is, that defaults in `self::class`
+ * (used as a pepper during the singing process) are specific for that a particular implementation.
+ *
+ * @internal
+ */
+trait JwtTrait
+{
+    private static function getDefaultSigningAlgorithm(): string
+    {
+        return 'HS256';
+    }
+
+    private static function createSigningKeyFromEncryptionKey(string $pepper = self::class): Key
+    {
+        if ($pepper === '') {
+            $pepper = self::class;
+        }
+        $encryptionKey = $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? '';
+        $keyMaterial = hash('sha256', $encryptionKey) . '/' . $pepper;
+        return new Key($keyMaterial, self::getDefaultSigningAlgorithm());
+    }
+
+    private static function createSigningSecret(SigningSecretInterface $secret, string $pepper = self::class): Key
+    {
+        if ($pepper === '') {
+            $pepper = self::class;
+        }
+        $keyMaterial = $secret->getSigningSecret() . '/' . $pepper;
+        return new Key($keyMaterial, self::getDefaultSigningAlgorithm());
+    }
+
+    private static function encodeHashSignedJwt(array $payload, Key $key, SecretIdentifier $identifier = null): string
+    {
+        // @todo work-around until https://github.com/firebase/php-jwt/pull/446/files is merged
+        $errorLevel = self::ignoreJwtPhp82Deprecations();
+
+        $keyId = $identifier !== null ? json_encode($identifier) : null;
+        $jwt = JWT::encode($payload, $key->getKeyMaterial(), self::getDefaultSigningAlgorithm(), $keyId);
+
+        if (is_int($errorLevel)) {
+            error_reporting($errorLevel);
+        }
+
+        return $jwt;
+    }
+
+    private static function decodeJwt(string $jwt, Key $key, bool $associative = false): \stdClass|array
+    {
+        // @todo work-around until https://github.com/firebase/php-jwt/pull/446/files is merged
+        $errorLevel = self::ignoreJwtPhp82Deprecations();
+
+        $payload = JWT::decode($jwt, $key);
+
+        if (is_int($errorLevel)) {
+            error_reporting($errorLevel);
+        }
+
+        return $associative ? json_decode(json_encode($payload), true) : $payload;
+    }
+
+    private static function decodeJwtHeader(string $jwt, string $property): mixed
+    {
+        $parts = explode('.', $jwt);
+        if (count($parts) !== 3) {
+            return null;
+        }
+        $headerRaw = JWT::urlsafeB64Decode($parts[0]);
+        if (($header = JWT::jsonDecode($headerRaw)) === null) {
+            return null;
+        }
+        return $header->{$property} ?? null;
+    }
+
+    private static function ignoreJwtPhp82Deprecations(): ?int
+    {
+        $php82 = version_compare(PHP_VERSION, '8.1.999', '>');
+        if (!$php82) {
+            return null;
+        }
+        $errorLevel = error_reporting();
+        return error_reporting($errorLevel ^ E_DEPRECATED);
+    }
+}
diff --git a/typo3/sysext/core/Classes/Security/Nonce.php b/typo3/sysext/core/Classes/Security/Nonce.php
new file mode 100644
index 000000000000..4a2ce06d9a80
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/Nonce.php
@@ -0,0 +1,90 @@
+<?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\Security;
+
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
+
+/**
+ * Number used once...
+ *
+ * @internal
+ */
+class Nonce implements SigningSecretInterface
+{
+    use JwtTrait;
+
+    protected const MIN_BYTES = 40;
+
+    public readonly string $b64;
+    public readonly \DateTimeImmutable $time;
+
+    public static function create(int $length = self::MIN_BYTES): self
+    {
+        return GeneralUtility::makeInstance(self::class, random_bytes(max(self::MIN_BYTES, $length)));
+    }
+
+    public static function fromHashSignedJwt(string $jwt): self
+    {
+        try {
+            $payload = self::decodeJwt($jwt, self::createSigningKeyFromEncryptionKey(), true);
+            return GeneralUtility::makeInstance(
+                self::class,
+                StringUtility::base64urlDecode($payload['nonce'] ?? ''),
+                \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null)
+            );
+        } catch (\Throwable $t) {
+            throw new NonceException('Could not reconstitute nonce', 1651771351, $t);
+        }
+    }
+
+    public function __construct(public readonly string $binary, \DateTimeImmutable $time = null)
+    {
+        if (strlen($this->binary) < self::MIN_BYTES) {
+            throw new \LogicException(
+                sprintf('Value must have at least %d bytes', self::MIN_BYTES),
+                1651785134
+            );
+        }
+        $this->b64 = StringUtility::base64urlEncode($this->binary);
+        // drop microtime, second is the minimum date-interval
+        $this->time = \DateTimeImmutable::createFromFormat(
+            \DateTimeImmutable::RFC3339,
+            ($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339)
+        );
+    }
+
+    public function getSigningIdentifier(): SecretIdentifier
+    {
+        return new SecretIdentifier('nonce', StringUtility::base64urlEncode(md5($this->binary, true)));
+    }
+
+    public function getSigningSecret(): string
+    {
+        return hash('sha256', $this->binary);
+    }
+
+    public function toHashSignedJwt(): string
+    {
+        $payload = [
+            'nonce' => $this->b64,
+            'time' => $this->time->format(\DateTimeImmutable::RFC3339),
+        ];
+        return self::encodeHashSignedJwt($payload, self::createSigningKeyFromEncryptionKey());
+    }
+}
diff --git a/typo3/sysext/core/Classes/Security/NonceException.php b/typo3/sysext/core/Classes/Security/NonceException.php
new file mode 100644
index 000000000000..46e5908a2b83
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/NonceException.php
@@ -0,0 +1,27 @@
+<?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\Security;
+
+use TYPO3\CMS\Core\Exception;
+
+/**
+ * @internal
+ */
+class NonceException extends Exception
+{
+}
diff --git a/typo3/sysext/core/Classes/Security/NoncePool.php b/typo3/sysext/core/Classes/Security/NoncePool.php
new file mode 100644
index 000000000000..2566737c89ac
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/NoncePool.php
@@ -0,0 +1,168 @@
+<?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\Security;
+
+/**
+ * @internal
+ */
+class NoncePool implements SigningProviderInterface
+{
+    /**
+     * maximum amount of items in pool
+     */
+    protected const DEFAULT_SIZE = 5;
+
+    /**
+     * items will expire after this amount of seconds
+     */
+    protected const DEFAULT_EXPIRATION = 900;
+
+    /**
+     * @var array{size: positive-int, expiration: int}
+     */
+    protected array $options;
+
+    /**
+     * @var array<string, Nonce>
+     */
+    protected array $items;
+
+    /**
+     * @var array<string, ?Nonce>
+     */
+    protected array $changeItems = [];
+
+    /**
+     * @param array $nonces
+     * @param array<string, mixed> $options
+     */
+    public function __construct(array $nonces = [], array $options = [])
+    {
+        $this->options = [
+            'size' => max(1, (int)($options['size'] ?? self::DEFAULT_SIZE)),
+            'expiration' => max(0, (int)($options['expiration'] ?? self::DEFAULT_EXPIRATION)),
+        ];
+
+        foreach ($nonces as $name => $value) {
+            if ($value !== null && !$value instanceof Nonce) {
+                throw new \LogicException(sprintf('Invalid valid for nonce "%s"', $name), 1664195013);
+            }
+        }
+        // filter valid items
+        $this->items = array_filter(
+            $nonces,
+            fn (?Nonce $item, string $name) => $item !== null
+                && $this->isValidNonceName($item, $name)
+                && $this->isNonceUpToDate($item),
+            ARRAY_FILTER_USE_BOTH
+        );
+        // items that were not valid -> to be revoked
+        $invalidItems = array_diff_key($nonces, $this->items);
+        $this->changeItems = array_fill_keys(array_keys($invalidItems), null);
+    }
+
+    public function findSigningSecret(string $name): ?Nonce
+    {
+        return $this->items[$name] ?? null;
+    }
+
+    public function provideSigningSecret(): Nonce
+    {
+        $items = array_filter($this->changeItems);
+        $nonce = reset($items);
+        if (!$nonce instanceof Nonce) {
+            $nonce = Nonce::create();
+            $this->emit($nonce);
+        }
+        return $nonce;
+    }
+
+    public function merge(self $other): self
+    {
+        $this->items = array_merge($this->items, $other->items);
+        $this->changeItems = array_merge($this->changeItems, $other->changeItems);
+        return $this;
+    }
+
+    public function purge(): self
+    {
+        $size = $this->options['size'];
+        $items = array_filter($this->items);
+        if (count($items) <= $size) {
+            return $this;
+        }
+        uasort($items, static fn (Nonce $a, Nonce $b) => $b->time <=> $a->time);
+        $exceedingItems = array_splice($items, $size, null, []);
+        foreach ($exceedingItems as $name => $_) {
+            $this->changeItems[$name] = null;
+        }
+        return $this;
+    }
+
+    public function emit(Nonce $nonce): self
+    {
+        $this->changeItems[$nonce->getSigningIdentifier()->name] = $nonce;
+        return $this;
+    }
+
+    public function revoke(Nonce $nonce): self
+    {
+        $this->revokeSigningSecret($nonce->getSigningIdentifier()->name);
+        return $this;
+    }
+
+    public function revokeSigningSecret(string $name): void
+    {
+        if (isset($this->items[$name])) {
+            $this->changeItems[$name] = null;
+        }
+    }
+
+    /**
+     * @return array<string, Nonce>
+     */
+    public function getEmittableNonces(): array
+    {
+        return array_filter($this->changeItems);
+    }
+
+    /**
+     * @return list<string>
+     */
+    public function getRevocableNames(): array
+    {
+        return array_keys(
+            array_diff_key($this->changeItems, $this->getEmittableNonces())
+        );
+    }
+
+    protected function isValidNonceName(Nonce $nonce, $name): bool
+    {
+        return $nonce->getSigningIdentifier()->name === $name;
+    }
+
+    protected function isNonceUpToDate(Nonce $nonce): bool
+    {
+        if ($this->options['expiration'] <= 0) {
+            return true;
+        }
+        $now = new \DateTimeImmutable();
+        $interval = new \DateInterval(sprintf('PT%dS', $this->options['expiration']));
+        return $nonce->time->add($interval) > $now;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Security/RequestToken.php b/typo3/sysext/core/Classes/Security/RequestToken.php
new file mode 100644
index 000000000000..fc0fc69fbc73
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/RequestToken.php
@@ -0,0 +1,120 @@
+<?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\Security;
+
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * @internal
+ */
+class RequestToken
+{
+    use JwtTrait;
+
+    public const PARAM_NAME = '__RequestToken';
+    public const HEADER_NAME = 'X-TYPO3-RequestToken';
+
+    public readonly string $scope;
+    public readonly \DateTimeImmutable $time;
+    /**
+     * @var array<int|string, mixed>
+     */
+    public readonly array $params;
+
+    /**
+     * Identifier that was used for signing, filled when decoding.
+     */
+    private ?SecretIdentifier $signingSecretIdentifier = null;
+
+    public static function create(string $scope): self
+    {
+        return GeneralUtility::makeInstance(self::class, $scope);
+    }
+
+    public static function fromHashSignedJwt(string $jwt, SigningSecretInterface|SigningSecretResolver $secret): self
+    {
+        // invokes resolver to retrieve corresponding secret
+        // a hint was stored in the `kid` (keyId) property of the JWT header
+        if ($secret instanceof SigningSecretResolver) {
+            $kid = (string)self::decodeJwtHeader($jwt, 'kid');
+            try {
+                $identifier = SecretIdentifier::fromJson($kid);
+                $secret = $secret->findByIdentifier($identifier);
+            } catch (\Throwable $t) {
+                throw new RequestTokenException('Could not reconstitute request token', 1664202134, $t);
+            }
+            if ($secret === null) {
+                throw new RequestTokenException('Could not reconstitute request token', 1664202135);
+            }
+        }
+
+        try {
+            $payload = self::decodeJwt($jwt, self::createSigningSecret($secret), true);
+            $subject = GeneralUtility::makeInstance(
+                self::class,
+                $payload['scope'] ?? '',
+                \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null),
+                $payload['params'] ?? []
+            );
+            $subject->signingSecretIdentifier = $secret->getSigningIdentifier();
+            return $subject;
+        } catch (\Throwable $t) {
+            throw new RequestTokenException('Could not reconstitute request token', 1651771352, $t);
+        }
+    }
+
+    public function __construct(string $scope, \DateTimeImmutable $time = null, array $params = [])
+    {
+        $this->scope = $scope;
+        // drop microtime, second is the minimum date-interval
+        $this->time = \DateTimeImmutable::createFromFormat(
+            \DateTimeImmutable::RFC3339,
+            ($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339)
+        );
+        $this->params = $params;
+    }
+
+    public function toHashSignedJwt(SigningSecretInterface $secret): string
+    {
+        $payload = [
+            'scope' => $this->scope,
+            'time' => $this->time->format(\DateTimeImmutable::RFC3339),
+            'params' => $this->params,
+        ];
+        return self::encodeHashSignedJwt(
+            $payload,
+            self::createSigningSecret($secret),
+            $secret->getSigningIdentifier()
+        );
+    }
+
+    public function withParams(array $params): self
+    {
+        return GeneralUtility::makeInstance(self::class, $this->scope, $this->time, $params);
+    }
+
+    public function withMergedParams(array $params): self
+    {
+        return $this->withParams(array_merge_recursive($this->params, $params));
+    }
+
+    public function getSigningSecretIdentifier(): ?SecretIdentifier
+    {
+        return $this->signingSecretIdentifier;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Security/RequestTokenException.php b/typo3/sysext/core/Classes/Security/RequestTokenException.php
new file mode 100644
index 000000000000..868b4642a3e9
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/RequestTokenException.php
@@ -0,0 +1,27 @@
+<?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\Security;
+
+use TYPO3\CMS\Core\Exception;
+
+/**
+ * @internal
+ */
+class RequestTokenException extends Exception
+{
+}
diff --git a/typo3/sysext/core/Classes/Security/SecretIdentifier.php b/typo3/sysext/core/Classes/Security/SecretIdentifier.php
new file mode 100644
index 000000000000..830d7f688d83
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/SecretIdentifier.php
@@ -0,0 +1,60 @@
+<?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\Security;
+
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Model used to identify a secret, without actually containing the secret value.
+ *
+ * @internal
+ */
+class SecretIdentifier implements \JsonSerializable
+{
+    public static function fromJson(string $json): self
+    {
+        return self::fromArray(
+            (array)json_decode($json, true, 8, JSON_THROW_ON_ERROR)
+        );
+    }
+
+    public static function fromArray(array $payload): self
+    {
+        $type = $payload['type'] ?? null;
+        $name = $payload['name'] ?? null;
+        if (!is_string($type) || !is_string($name)) {
+            throw new \LogicException('Properties "type" and "name" must be of type string', 1664215980);
+        }
+        return GeneralUtility::makeInstance(self::class, $type, $name);
+    }
+
+    public function __construct(public readonly string $type, public readonly string $name)
+    {
+    }
+
+    /**
+     * @return array{type: string, name: string}
+     */
+    public function jsonSerialize(): array
+    {
+        return [
+            'type' => $this->type,
+            'name' => $this->name,
+        ];
+    }
+}
diff --git a/typo3/sysext/core/Classes/Security/SigningProviderInterface.php b/typo3/sysext/core/Classes/Security/SigningProviderInterface.php
new file mode 100644
index 000000000000..ee373186b038
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/SigningProviderInterface.php
@@ -0,0 +1,41 @@
+<?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\Security;
+
+/**
+ * @internal
+ */
+interface SigningProviderInterface
+{
+    /**
+     * Provides a signing secret independently of any name or identifier.
+     * In case there is none, the corresponding provider has to create a new one.
+     */
+    public function provideSigningSecret(): SigningSecretInterface;
+
+    /**
+     * Finds a signing secret for a given name
+     */
+    public function findSigningSecret(string $name): ?SigningSecretInterface;
+
+    /**
+     * Revokes a signing secret for a given name
+     * (providers without revocation functionality use an empty method body)
+     */
+    public function revokeSigningSecret(string $name): void;
+}
diff --git a/typo3/sysext/core/Classes/Security/SigningSecretInterface.php b/typo3/sysext/core/Classes/Security/SigningSecretInterface.php
new file mode 100644
index 000000000000..f20e87342ada
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/SigningSecretInterface.php
@@ -0,0 +1,36 @@
+<?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\Security;
+
+/**
+ * Provides the value that is used as secret in a cryptographic signing process.
+ *
+ * @internal
+ */
+interface SigningSecretInterface
+{
+    /**
+     * Returns a public identifier of the secret.
+     */
+    public function getSigningIdentifier(): SecretIdentifier;
+
+    /**
+     * Returns secret used for signing messages.
+     */
+    public function getSigningSecret(): string;
+}
diff --git a/typo3/sysext/core/Classes/Security/SigningSecretResolver.php b/typo3/sysext/core/Classes/Security/SigningSecretResolver.php
new file mode 100644
index 000000000000..0884e02656f6
--- /dev/null
+++ b/typo3/sysext/core/Classes/Security/SigningSecretResolver.php
@@ -0,0 +1,73 @@
+<?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\Security;
+
+/**
+ * Resolves SigningSecretInterface items.
+ *
+ * @internal This class with change!
+ */
+class SigningSecretResolver
+{
+    /**
+     * @var array<string, SigningProviderInterface>
+     */
+    protected array $providers;
+
+    /**
+     * @param array $providers
+     */
+    public function __construct(array $providers)
+    {
+        $this->providers = array_filter(
+            $providers,
+            static fn ($provider) => $provider instanceof SigningProviderInterface
+        );
+    }
+
+    /**
+     * Resolves a signing provider by its type (e.g. `NoncePool` from type `'nonce'`)
+     */
+    public function findByType(string $type): ?SigningProviderInterface
+    {
+        return $this->providers[$type] ?? null;
+    }
+
+    /**
+     * Resolves a specific signing secret by its public identifier
+     * (e.g. specific `Nonce` from `NoncePool` by given public identifier "nonce:[public-name]")
+     */
+    public function findByIdentifier(SecretIdentifier $identifier): ?SigningSecretInterface
+    {
+        if (!isset($this->providers[$identifier->type])) {
+            return null;
+        }
+        return $this->providers[$identifier->type]->findSigningSecret($identifier->name);
+    }
+
+    /**
+     * Revokes a specific signing secret.
+     */
+    public function revokeIdentifier(SecretIdentifier $identifier): void
+    {
+        if (!isset($this->providers[$identifier->type])) {
+            return;
+        }
+        $this->providers[$identifier->type]->revokeSigningSecret($identifier->name);
+    }
+}
diff --git a/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst b/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst
new file mode 100644
index 000000000000..5f34a09d02f6
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst
@@ -0,0 +1,74 @@
+.. include:: /Includes.rst.txt
+
+.. _breaking-97305-1664100009:
+
+==================================================
+Breaking: #97305 - Introduce CSRF-like login token
+==================================================
+
+See :issue:`97305`
+
+Description
+===========
+
+:php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` requires a
+CSRF-like request-token to continue with the authentication process and to
+create an actual server-side user session.
+
+The request-token has to be submitted by one of these ways:
+
+* HTTP body, e.g. in `<form>` via parameter `__request_token`
+* HTTP header, e.g. in XHR via header `X-TYPO3-Request-Token`
+
+Impact
+======
+
+Core user authentication is protected by a CSRF-like request-token, to
+mitigate `Login CSRF <https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html>`__.
+
+Custom implementations for login templates or client-side authentication
+handling have to be adjusted to submit the required request-token.
+
+
+Affected installations
+======================
+
+Sites having custom implementations for login templates or client-side authentication.
+
+Migration
+=========
+
+The :php:`\TYPO3\CMS\Core\Security\RequestToken` signed with a :php:`\TYPO3\CMS\Core\Security\Nonce`
+needs to be sent as JSON Web Token (JWT) to the server-side application handling of
+the core user authentication process. The scope needs to be :php:`core/user-auth/be`
+or :php:`core/user-auth/fe` - depending on whether authentication is applied in
+the website's backend or frontend context.
+
+
+
+Example for overridden backend login HTML template (`ext:backend`)
+------------------------------------------------------------------
+
+..  code-block:: diff
+
+    --- a/typo3/sysext/backend/Resources/Private/Layouts/Login.html
+    +++ b/typo3/sysext/backend/Resources/Private/Layouts/Login.html
+     <input type="hidden" name="redirect_url" value="{redirectUrl}" />
+     <input type="hidden" name="loginRefresh" value="{loginRefresh}" />
+    +<input type="hidden" name="{requestTokenName}" value="{requestTokenValue}" />
+
+Example for overridden frontend login HTML template (`ext:felogin`)
+-------------------------------------------------------------------
+
+..  code-block:: diff
+
+    --- a/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html
+    +++ b/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html
+    -<f:form target="_top" fieldNamePrefix="" action="login">
+    +<f:form target="_top" fieldNamePrefix="" action="login" requestToken="{requestToken}">
+
+More details are explained in corresponding documentation on
+:ref:`Feature #87616: Introduce CSRF-like request-token handling <feature-97305-1664099950>`.
+
+
+.. index:: Backend, Fluid, Frontend, NotScanned, ext:core
diff --git a/typo3/sysext/core/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst b/typo3/sysext/core/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst
new file mode 100644
index 000000000000..736dbe4ea61b
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst
@@ -0,0 +1,155 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-97305-1664099950:
+
+============================================================
+Feature: #97305 - Introduce CSRF-like request-token handling
+============================================================
+
+See :issue:`97305`
+
+Description
+===========
+
+A CSRF-like request-token handling has been introduced to mitigate
+potential cross-site requests on actions with side-effects. This approach
+does not require an existing server-side user session, but uses a nonce
+(number used once) as a "pre-session". The main scope is to ensure a user
+actually has visited a page, before submitting data to the web server.
+
+This token can only be used for HTTP methods `POST`, `PUT` or `PATCH`, but
+for instance not for `GET` request.
+
+New :php:`\TYPO3\CMS\Core\Middleware\RequestTokenMiddleware` resolves
+request-tokens and nonce values from a request and enhances responses with
+a nonce value in case the underlying application issues one. Both items are
+serialized as JSON Web Token (JWT) hash signed with `HS256`. Request-tokens
+use the provided nonce value during signing.
+
+Session cookie names involved for providing the nonce value:
+
+* `typo3nonce_[hash]` in case request served with plain HTTP
+* `__Secure-typo3_nonce` in case request served with secured HTTPS
+
+Submitting request-token value to application:
+
+* HTTP body, e.g. in `<form>` via parameter `__request_token`
+* HTTP header, e.g. in XHR via header `X-TYPO3-Request-Token`
+
+
+The sequence looks like the following:
+
+1. Retrieve nonce and request-token values
+------------------------------------------
+
+This happens on the previous legitimate visit on a page that offers
+a corresponding form that shall be protected. The `RequestToken` and `Nonce`
+objects (later created implicitly in this example) are organized in new
+:php:`\TYPO3\CMS\Core\Context\SecurityAspect`.
+
+.. code-block:: php
+
+    class MyController
+    {
+        protected \TYPO3\CMS\Fluid\View\StandaloneView $view;
+        protected \TYPO3\CMS\Core\Context\Context $context;
+
+        public function showFormAction()
+        {
+            // creating new request-token with scope 'my/process' and hand over to view
+            $requestToken = \TYPO3\CMS\Core\Security\RequestToken::create('my/process');
+            $this->view->assign('requestToken', $requestToken)
+            // ...
+        }
+
+        public function processAction() {}
+    }
+
+.. code-block:: html
+
+    <!-- in ShowForm.html template: assign request-token object for view-helper -->
+    <f:form action="process" requestToken="{requestToken}>...</f:form>
+
+The HTTP response on calling the shown controller-action above will be like this:
+
+.. code-block:: text
+
+    HTTP/1.1 200 OK
+    Content-Type: text/html; charset=utf-8
+    Set-Cookie: typo3nonce_[hash]=[nonce-as-jwt]; path=/; httponly; samesite=strict
+
+    ...
+    <form action="/my/process" method="post">
+        ...
+        <input type="hidden" name="__request_token" value="[request-token-as-jwt]">
+        ...
+    </form>
+
+2. Invoke action request and provide nonce and request-token values
+-------------------------------------------------------------------
+
+When submitting the form and invoking the corresponding action, same-site
+cookies `typo3nonce_[hash]` and request-token value `__request_token` are sent
+back to the server. Without using a separate nonce in a scope that is protected
+by the client, corresponding request-token could be easily extracted from markup
+and used without having the possibility to verify the procedural integrity.
+
+Middleware :php:`\TYPO3\CMS\Core\Middleware\RequestTokenMiddleware` takes care
+of providing received nonce and received request-token values in
+:php:`\TYPO3\CMS\Core\Context\SecurityAspect`. The handling controller-action
+needs to verify that the request-token has the expected `'my/process'` scope.
+
+.. code-block:: php
+
+    class MyController
+    {
+        protected \TYPO3\CMS\Fluid\View\StandaloneView $view;
+        protected \TYPO3\CMS\Core\Context\Context $context;
+
+        public function showFormAction() {}
+
+        public function processAction()
+        {
+            $securityAspect = \TYPO3\CMS\Core\Context\SecurityAspect::provideIn($this->context);
+            $requestToken = $securityAspect->getReceivedRequestToken();
+
+            if ($requestToken === null) {
+                // no request-token was provided in request
+                // e.g. (overridden) templates need to be adjusted
+            } elseif ($requestToken === false) {
+                // there was a request-token, which could not be verified with the nonce
+                // e.g. when nonce cookie has been overridden by another HTTP request
+            } elseif ($requestToken->scope !== 'my/process') {
+                // there was a request-token, but for a different scope
+                // e.g. when a form with different scope was submitted
+            } else {
+                // request-token was valid and for the expected scope
+                $this->doTheMagic();
+                // middleware takes care to remove the the cookie in case no other
+                // nonce value shall be emitted during the current HTTP request
+                $requestToken->getSigningSecretIdentifier() !== null) {
+                    $securityAspect->getSigningSecretResolver()->revokeIdentifier(
+                        $requestToken->getSigningSecretIdentifier()
+                    );
+                }
+            }
+        }
+    }
+
+
+Impact
+======
+
+In case a form is protected with the new request-token, actors have to visit the
+page containing the form before being able to actually submit data to the
+underlying server-side processing.
+
+When working with multiple browser tabs, an existing nonce value (stored as
+session cookie in users' browser) might be overridden.
+
+The current concept uses a :php:`\TYPO3\CMS\Core\Security\NoncePool` which
+supports five different nonces in the same request. The pool purges nonces
+15 minutes (900 seconds) after they have been issued.
+
+
+.. index:: Backend, Fluid, Frontend, PHP-API, ext:core
diff --git a/typo3/sysext/core/Tests/Unit/Context/SecurityAspectTest.php b/typo3/sysext/core/Tests/Unit/Context/SecurityAspectTest.php
new file mode 100644
index 000000000000..9fb73ce1161a
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Context/SecurityAspectTest.php
@@ -0,0 +1,60 @@
+<?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\Context;
+
+use TYPO3\CMS\Core\Context\SecurityAspect;
+use TYPO3\CMS\Core\Security\NoncePool;
+use TYPO3\CMS\Core\Security\RequestToken;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class SecurityAspectTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function receivedRequestTokenIsFunctional(): void
+    {
+        $aspect = new SecurityAspect();
+        self::assertNull($aspect->getReceivedRequestToken());
+        $aspect->setReceivedRequestToken(false);
+        self::assertFalse($aspect->getReceivedRequestToken());
+        $token = RequestToken::create(self::class);
+        $aspect->setReceivedRequestToken($token);
+        self::assertSame($token, $aspect->getReceivedRequestToken());
+        $aspect->setReceivedRequestToken(null);
+        self::assertNull($aspect->getReceivedRequestToken());
+    }
+
+    /**
+     * @test
+     */
+    public function signingSecretResolverIsFunctional(): void
+    {
+        $aspect = new SecurityAspect();
+        self::assertInstanceOf(NoncePool::class, $aspect->getSigningSecretResolver()->findByType('nonce'));
+    }
+
+    /**
+     * @test
+     */
+    public function noncePoolIsFunctional(): void
+    {
+        $aspect = new SecurityAspect();
+        self::assertInstanceOf(NoncePool::class, $aspect->getNoncePool());
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Security/NoncePoolTest.php b/typo3/sysext/core/Tests/Unit/Security/NoncePoolTest.php
new file mode 100644
index 000000000000..5435fb6eee34
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Security/NoncePoolTest.php
@@ -0,0 +1,135 @@
+<?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\Security;
+
+use TYPO3\CMS\Core\Security\Nonce;
+use TYPO3\CMS\Core\Security\NoncePool;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class NoncePoolTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function instantiationReflectsState(): void
+    {
+        $items = self::createItems();
+        $validItems = array_slice($items, 0, 3);
+        $pool = new NoncePool($items);
+
+        foreach ($validItems as $name => $validItem) {
+            self::assertSame($validItem, $pool->findSigningSecret($name));
+        }
+        self::assertSame(['rejected-name', 'revoked-a', 'revoked-b', 'revoked-c'], $pool->getRevocableNames());
+        self::assertSame([], $pool->getEmittableNonces());
+    }
+
+    /**
+     * @test
+     */
+    public function itemsAreMerged(): void
+    {
+        $itemsA = self::createItems();
+        $itemsB = self::createItems();
+        $validItems = array_merge(
+            array_slice($itemsA, 0, 3),
+            array_slice($itemsB, 0, 3)
+        );
+        $poolA = new NoncePool($itemsA);
+        $poolB = new NoncePool($itemsB);
+        $poolA->merge($poolB);
+
+        foreach ($validItems as $name => $validItem) {
+            self::assertSame($validItem, $poolA->findSigningSecret($name));
+        }
+        self::assertSame(['rejected-name', 'revoked-a', 'revoked-b', 'revoked-c'], $poolA->getRevocableNames());
+        self::assertSame([], $poolA->getEmittableNonces());
+    }
+
+    /**
+     * @test
+     */
+    public function provideSigningSecretDoesNotUseReceivedNonce(): void
+    {
+        $items = self::createItems();
+        $pool = new NoncePool($items);
+        $nonceA = $pool->provideSigningSecret();
+        $nonceB = $pool->provideSigningSecret();
+        self::assertSame($nonceA, $nonceB);
+        self::assertNotContains($nonceA, $items);
+    }
+
+    public static function itemsArePurgedDataProvider(): \Generator
+    {
+        $items = self::createItems();
+        $validItems = array_slice($items, 0, 3);
+        yield [
+            ['size' => 1],
+            $items,
+            $validItems,
+            self::getArrayKeysDiff($items, array_slice($items, 0, 1)),
+        ];
+        yield [
+            ['size' => 2],
+            $items,
+            $validItems,
+            self::getArrayKeysDiff($items, array_slice($items, 0, 2)),
+        ];
+        yield [
+            ['size' => 10],
+            $items,
+            $validItems,
+            self::getArrayKeysDiff($items, $validItems),
+        ];
+    }
+
+    /**
+     * @test
+     * @dataProvider itemsArePurgedDataProvider
+     */
+    public function itemsArePurged(array $options, array $items, array $validItems, array $revocableNames): void
+    {
+        $pool = (new NoncePool($items, $options))->purge();
+        foreach ($validItems as $name => $validItem) {
+            self::assertSame($validItem, $pool->findSigningSecret($name));
+        }
+        self::assertEmpty(array_diff($revocableNames, $pool->getRevocableNames()));
+    }
+
+    private static function createItems(): array
+    {
+        $nonceA = Nonce::create();
+        $nonceB = Nonce::create();
+        $nonceC = Nonce::create();
+        return [
+            $nonceA->getSigningIdentifier()->name => $nonceA,
+            $nonceB->getSigningIdentifier()->name => $nonceB,
+            $nonceC->getSigningIdentifier()->name => $nonceC,
+            'rejected-name' => Nonce::create(),
+            'revoked-a' => null,
+            'revoked-b' => null,
+            'revoked-c' => null,
+        ];
+    }
+
+    private static function getArrayKeysDiff(array $items, array $without): array
+    {
+        $diff = array_diff_key($items, $without);
+        return array_keys($diff);
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Security/NonceTest.php b/typo3/sysext/core/Tests/Unit/Security/NonceTest.php
new file mode 100644
index 000000000000..e5ba589267ba
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Security/NonceTest.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\Tests\Unit\Security;
+
+use TYPO3\CMS\Core\Security\Nonce;
+use TYPO3\CMS\Core\Security\NonceException;
+use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class NonceTest extends UnitTestCase
+{
+    public function nonceIsCreatedDataProvider(): \Generator
+    {
+        yield [0, 40];
+        yield [20, 40];
+        yield [40, 40];
+        yield [60, 60];
+    }
+
+    /**
+     * @test
+     * @dataProvider nonceIsCreatedDataProvider
+     */
+    public function isCreated(int $length, int $expectedLength): void
+    {
+        $nonce = Nonce::create($length);
+        self::assertSame($expectedLength, strlen($nonce->binary));
+        self::assertSame($nonce->b64, StringUtility::base64urlEncode($nonce->binary));
+    }
+
+    /**
+     * @test
+     */
+    public function isCreatedWithProperties(): void
+    {
+        $binary = random_bytes(40);
+        $time = $this->createRandomTime();
+        $nonce = new Nonce($binary, $time);
+        self::assertSame($binary, $nonce->binary);
+        self::assertEquals($time, $nonce->time);
+    }
+
+    /**
+     * @test
+     */
+    public function isEncodedAndDecoded(): void
+    {
+        $nonce = Nonce::create();
+        $recodedNonce = Nonce::fromHashSignedJwt($nonce->toHashSignedJwt());
+        self::assertEquals($recodedNonce, $nonce);
+    }
+
+    /**
+     * @test
+     */
+    public function invalidJwtThrowsException(): void
+    {
+        $this->expectException(NonceException::class);
+        $this->expectExceptionCode(1651771351);
+        Nonce::fromHashSignedJwt('no-jwt-at-all');
+    }
+
+    private function createRandomTime(): \DateTimeImmutable
+    {
+        // drop microtime, second is the minimum date-interval here
+        $now = \DateTimeImmutable::createFromFormat(
+            \DateTimeImmutable::RFC3339,
+            (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339)
+        );
+        $delta = random_int(-7200, 7200);
+        $interval = new \DateInterval(sprintf('PT%dS', abs($delta)));
+        return $delta < 0 ? $now->sub($interval) : $now->add($interval);
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Security/RequestTokenTest.php b/typo3/sysext/core/Tests/Unit/Security/RequestTokenTest.php
new file mode 100644
index 000000000000..9e29b3a5bee5
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Security/RequestTokenTest.php
@@ -0,0 +1,131 @@
+<?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\Security;
+
+use TYPO3\CMS\Core\Security\Nonce;
+use TYPO3\CMS\Core\Security\RequestToken;
+use TYPO3\CMS\Core\Security\RequestTokenException;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class RequestTokenTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function isCreated(): void
+    {
+        $scope = $this->createRandomString();
+        $token = RequestToken::create($scope);
+        $now = $this->createCurrentTime();
+        self::assertSame($scope, $token->scope);
+        self::assertEquals($now, $token->time);
+        self::assertSame([], $token->params);
+    }
+
+    /**
+     * @test
+     */
+    public function isCreatedWithProperties(): void
+    {
+        $scope = $this->createRandomString();
+        $time = $this->createRandomTime();
+        $params = ['value' => bin2hex(random_bytes(4))];
+        $token = new RequestToken($scope, $time, $params);
+        self::assertSame($scope, $token->scope);
+        self::assertEquals($time, $token->time);
+        self::assertSame($params, $token->params);
+    }
+
+    /**
+     * @test
+     */
+    public function paramsAreOverriddenInNewInstance(): void
+    {
+        $scope = $this->createRandomString();
+        $params = ['nested' => ['value' => bin2hex(random_bytes(4))]];
+        $token = RequestToken::create($scope)->withParams(['nested' => ['original' => true]]);
+        $modifiedToken = $token->withParams($params);
+        self::assertNotSame($token, $modifiedToken);
+        self::assertSame($params, $modifiedToken->params);
+    }
+
+    /**
+     * @test
+     */
+    public function paramsAreMergedInNewInstance(): void
+    {
+        $scope = $this->createRandomString();
+        $params = ['nested' => ['value' => bin2hex(random_bytes(4))]];
+        $token = RequestToken::create($scope)->withParams(['nested' => ['original' => true]]);
+        $modifiedToken = $token->withMergedParams($params);
+        self::assertNotSame($token, $modifiedToken);
+        self::assertSame(array_merge_recursive($token->params, $params), $modifiedToken->params);
+    }
+
+    /**
+     * @test
+     */
+    public function isEncodedAndDecoded(): void
+    {
+        $scope = $this->createRandomString();
+        $time = $this->createRandomTime();
+        $params = ['value' => bin2hex(random_bytes(4))];
+        $token = new RequestToken($scope, $time, $params);
+
+        $nonce = Nonce::create();
+        $recodedToken = RequestToken::fromHashSignedJwt($token->toHashSignedJwt($nonce), $nonce);
+        self::assertSame($recodedToken->scope, $token->scope);
+        self::assertEquals($recodedToken->time, $token->time);
+        self::assertSame($recodedToken->params, $token->params);
+        self::assertSame('nonce', $recodedToken->getSigningSecretIdentifier()->type);
+        self::assertEquals($nonce->getSigningIdentifier(), $recodedToken->getSigningSecretIdentifier());
+    }
+
+    /**
+     * @test
+     */
+    public function invalidJwtThrowsException(): void
+    {
+        $nonce = Nonce::create();
+        $this->expectException(RequestTokenException::class);
+        $this->expectExceptionCode(1651771352);
+        RequestToken::fromHashSignedJwt('no-jwt-at-all', $nonce);
+    }
+
+    private function createRandomString(): string
+    {
+        return bin2hex(random_bytes(4));
+    }
+
+    private function createRandomTime(): \DateTimeImmutable
+    {
+        $now = $this->createCurrentTime();
+        $delta = random_int(-7200, 7200);
+        $interval = new \DateInterval(sprintf('PT%dS', abs($delta)));
+        return $delta < 0 ? $now->sub($interval) : $now->add($interval);
+    }
+
+    private function createCurrentTime(): \DateTimeImmutable
+    {
+        // drop microtime, second is the minimum date-interval
+        return \DateTimeImmutable::createFromFormat(
+            \DateTimeImmutable::RFC3339,
+            (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339)
+        );
+    }
+}
diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json
index 2b89acc1a726..17f2104d330c 100644
--- a/typo3/sysext/core/composer.json
+++ b/typo3/sysext/core/composer.json
@@ -38,6 +38,7 @@
 		"doctrine/lexer": "^1.2.3",
 		"egulias/email-validator": "^3.2.1",
 		"enshrined/svg-sanitize": "^0.15.4",
+		"firebase/php-jwt": "^6.3",
 		"guzzlehttp/guzzle": "^7.4.5",
 		"guzzlehttp/psr7": "^1.8.5 || ^2.1.2",
 		"lolli42/finediff": "^1.0.2",
diff --git a/typo3/sysext/felogin/Classes/Controller/LoginController.php b/typo3/sysext/felogin/Classes/Controller/LoginController.php
index 7b321fe51049..ede5349ec197 100644
--- a/typo3/sysext/felogin/Classes/Controller/LoginController.php
+++ b/typo3/sysext/felogin/Classes/Controller/LoginController.php
@@ -21,6 +21,7 @@ use Psr\Http\Message\ResponseInterface;
 use TYPO3\CMS\Core\Authentication\LoginType;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Context\UserAspect;
+use TYPO3\CMS\Core\Security\RequestToken;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Extbase\Http\ForwardResponse;
 use TYPO3\CMS\FrontendLogin\Configuration\RedirectConfiguration;
@@ -108,6 +109,7 @@ class LoginController extends AbstractLoginFormController
                 'redirectReferrer' => $this->request->hasArgument('redirectReferrer') ? (string)$this->request->getArgument('redirectReferrer'): '',
                 'referer' => $this->requestHandler->getPropertyFromGetAndPost('referer'),
                 'noRedirect' => $this->isRedirectDisabled(),
+                'requestToken' => RequestToken::create('core/user-auth/fe'),
             ]
         );
 
diff --git a/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html b/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html
index 4c549c67017f..9491676bfb73 100644
--- a/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html
+++ b/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html
@@ -15,12 +15,12 @@
 </f:if>
 <f:if condition="{onSubmit}">
     <f:then>
-        <f:form target="_top" fieldNamePrefix="" action="login" onsubmit="{onSubmit}">
+        <f:form target="_top" fieldNamePrefix="" action="login" onsubmit="{onSubmit}" requestToken="{requestToken}">
             <f:render section="content" arguments="{_all}"/>
         </f:form>
     </f:then>
     <f:else>
-        <f:form target="_top" fieldNamePrefix="" action="login">
+        <f:form target="_top" fieldNamePrefix="" action="login" requestToken="{requestToken}">
             <f:render section="content" arguments="{_all}"/>
         </f:form>
     </f:else>
diff --git a/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php b/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php
index 749ea65ec6f9..f76f9119e030 100644
--- a/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php
+++ b/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php
@@ -18,7 +18,10 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Fluid\ViewHelpers;
 
 use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\SecurityAspect;
 use TYPO3\CMS\Core\Http\ApplicationType;
+use TYPO3\CMS\Core\Security\RequestToken;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
 use TYPO3\CMS\Extbase\Mvc\Controller\MvcPropertyMappingConfigurationService;
@@ -124,6 +127,8 @@ class FormViewHelper extends AbstractFormViewHelper
         $this->registerArgument('actionUri', 'string', 'can be used to overwrite the "action" attribute of the form tag');
         $this->registerArgument('objectName', 'string', 'name of the object that is bound to this form. If this argument is not specified, the name attribute of this form is used to determine the FormObjectName');
         $this->registerArgument('hiddenFieldClassName', 'string', 'hiddenFieldClassName');
+        $this->registerArgument('requestToken', 'mixed', 'whether to add that request token to the form');
+        $this->registerArgument('signingType', 'string', 'which signing type to be used on the request token (falls back to "nonce")');
         $this->registerTagAttribute('enctype', 'string', 'MIME type with which the form is submitted');
         $this->registerTagAttribute('method', 'string', 'Transfer type (GET or POST)');
         $this->registerTagAttribute('name', 'string', 'Name of form');
@@ -161,6 +166,7 @@ class FormViewHelper extends AbstractFormViewHelper
         $this->addFormObjectToViewHelperVariableContainer();
         $this->addFieldNamePrefixToViewHelperVariableContainer();
         $this->addFormFieldNamesToViewHelperVariableContainer();
+
         $formContent = $this->renderChildren();
 
         if (isset($this->arguments['hiddenFieldClassName']) && $this->arguments['hiddenFieldClassName'] !== null) {
@@ -172,6 +178,7 @@ class FormViewHelper extends AbstractFormViewHelper
         $content .= $this->renderHiddenIdentityField($this->arguments['object'] ?? null, $this->getFormObjectName());
         $content .= $this->renderAdditionalIdentityFields();
         $content .= $this->renderHiddenReferrerFields();
+        $content .= $this->renderRequestTokenHiddenField();
 
         // Render the trusted list of all properties after everything else has been rendered
         $content .= $this->renderTrustedPropertiesField();
@@ -444,4 +451,47 @@ class FormViewHelper extends AbstractFormViewHelper
         $requestHash = $this->mvcPropertyMappingConfigurationService->generateTrustedPropertiesToken($formFieldNames, $this->getFieldNamePrefix());
         return '<input type="hidden" name="' . htmlspecialchars($this->prefixFieldName('__trustedProperties')) . '" value="' . htmlspecialchars($requestHash) . '" />';
     }
+
+    protected function renderRequestTokenHiddenField(): string
+    {
+        $requestToken = $this->arguments['requestToken'] ?? null;
+        $signingType = $this->arguments['signingType'] ?? null;
+
+        $isTrulyRequestToken = is_int($requestToken) && $requestToken === 1
+            || is_string($requestToken) && strtolower($requestToken) === 'true';
+        $formAction = $this->tag->getAttribute('action');
+
+        // basically "request token, yes" - uses form-action URI as scope
+        if ($isTrulyRequestToken || $requestToken === '@nonce') {
+            $requestToken = RequestToken::create($formAction);
+        // basically "request token with 'my-scope'" - uses 'my-scope'
+        } elseif (is_string($requestToken) && $requestToken !== '') {
+            $requestToken = RequestToken::create($requestToken);
+        }
+        if (!$requestToken instanceof RequestToken) {
+            return '';
+        }
+        if (strtolower((string)($this->arguments['method'] ?? '')) === 'get') {
+            throw new \LogicException('Cannot apply request token for forms sent via HTTP GET', 1651775963);
+        }
+
+        $context = GeneralUtility::makeInstance(Context::class);
+        $securityAspect = SecurityAspect::provideIn($context);
+        // @todo currently defaults to 'nonce', there might be a better strategy in the future
+        $signingType = $signingType ?: 'nonce';
+        $signingProvider = $securityAspect->getSigningSecretResolver()->findByType($signingType);
+        if ($signingProvider === null) {
+            throw new \LogicException(sprintf('Cannot find request token signing type "%s"', $signingType), 1664260307);
+        }
+
+        $signingSecret = $signingProvider->provideSigningSecret();
+        $requestToken = $requestToken->withMergedParams(['request' => ['uri' => $formAction]]);
+
+        $attrs = [
+            'type' => 'hidden',
+            'name' => RequestToken::PARAM_NAME,
+            'value' => $requestToken->toHashSignedJwt($signingSecret),
+        ];
+        return '<input ' . GeneralUtility::implodeAttributes($attrs, true) . '/>';
+    }
 }
diff --git a/typo3/sysext/frontend/Configuration/RequestMiddlewares.php b/typo3/sysext/frontend/Configuration/RequestMiddlewares.php
index a08fe5ed25fc..14a9ae4bb0b6 100644
--- a/typo3/sysext/frontend/Configuration/RequestMiddlewares.php
+++ b/typo3/sysext/frontend/Configuration/RequestMiddlewares.php
@@ -90,6 +90,16 @@ return [
                 'typo3/cms-frontend/page-resolver',
             ],
         ],
+        'typo3/cms-core/request-token-middleware' => [
+            'target' => \TYPO3\CMS\Core\Middleware\RequestTokenMiddleware::class,
+            'after' => [
+                'typo3/cms-frontend/site',
+            ],
+            'before' => [
+                'typo3/cms-frontend/backend-user-authentication',
+                'typo3/cms-frontend/authentication',
+            ],
+        ],
         'typo3/cms-frontend/backend-user-authentication' => [
             'target' => \TYPO3\CMS\Frontend\Middleware\BackendUserAuthenticator::class,
             'before' => [
diff --git a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php
index b3242f10a847..2226dc9bb1ba 100644
--- a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php
+++ b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php
@@ -24,11 +24,13 @@ use Psr\Http\Message\ServerRequestInterface;
 use Psr\Log\NullLogger;
 use TYPO3\CMS\Core\Authentication\AuthenticationService;
 use TYPO3\CMS\Core\Authentication\IpLocker;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\SecurityAspect;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
-use TYPO3\CMS\Core\Http\Request;
 use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\CMS\Core\Security\RequestToken;
 use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
 use TYPO3\CMS\Core\Session\UserSession;
 use TYPO3\CMS\Core\Session\UserSessionManager;
@@ -272,6 +274,12 @@ class FrontendUserAuthenticationTest extends UnitTestCase
     {
         $GLOBALS['BE_USER'] = [];
 
+        // provide request-token
+        $context = GeneralUtility::makeInstance(Context::class);
+        $securityAspect = SecurityAspect::provideIn($context);
+        $requestToken = RequestToken::create('core/user-auth/fe');
+        $securityAspect->setReceivedRequestToken($requestToken);
+
         // Main session backend setup
         $userSession = UserSession::createNonFixated('newSessionId');
         $elevatedUserSession = UserSession::createFromRecord('newSessionId', ['ses_userid' => 1], true);
@@ -311,6 +319,6 @@ class FrontendUserAuthenticationTest extends UnitTestCase
         // We need to wrap the array to something thats is \Traversable, in PHP 7.1 we can use traversable pseudo type instead
         $subject->method('getAuthServices')->willReturn(new \ArrayIterator([$authServiceMock]));
         $subject->start($this->prophesize(ServerRequestInterface::class)->reveal());
-        self::assertEquals('existingUserName', $subject->user['username']);
+        self::assertEquals('existingUserName', $subject->user['username'] ?? null);
     }
 }
-- 
GitLab