diff --git a/modules/saml/docs/authproc_pairwiseid.md b/modules/saml/docs/authproc_pairwiseid.md
new file mode 100644
index 0000000000000000000000000000000000000000..772b6a5ae397b56d7fb16841d89e02cb991b1b55
--- /dev/null
+++ b/modules/saml/docs/authproc_pairwiseid.md
@@ -0,0 +1,28 @@
+`subjectidattrs:PairwiseID`
+===================
+
+Filter to insert a pairwise-id that complies with the
+[SAML V2.0 Subject Identifier Attributes Profile][specification].
+
+[specification]: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/saml-subject-id-attr-v1.0.pdf
+
+This filter will take an attribute and a scope as input and transforms this
+into a anonymized and scoped identifier that is globally unique for a given
+user & service provider combination.
+
+Note:
+Since the subject-id is specified as single-value attribute, only the first
+value of `identifyingAttribute` and `scopeAttribute` are considered.
+
+Examples
+--------
+
+```php
+    'authproc' => [
+        50 => [
+            'class' => 'subjectidattrs:PairwiseID',
+            'identifyingAttribute' => 'uid',
+            'scopeAttribute' => 'scope',
+        ],
+    ],
+```
diff --git a/modules/saml/docs/authproc_subjectid.md b/modules/saml/docs/authproc_subjectid.md
new file mode 100644
index 0000000000000000000000000000000000000000..1752ddbfccc22df54d0d4977dac6ba81afd39ee0
--- /dev/null
+++ b/modules/saml/docs/authproc_subjectid.md
@@ -0,0 +1,31 @@
+`subjectidattrs:SubjectID`
+===================
+
+Filter to insert a subject-id that complies with the
+[SAML V2.0 Subject Identifier Attributes Profile][specification].
+
+[specification]: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/saml-subject-id-attr-v1.0.pdf
+
+This filter will take an attribute and a scope as input and transforms this
+into a scoped identifier that is globally unique for a given user.
+
+**Note**
+If privacy is of your concern, you may want to use the PairwiseID-filter
+instead.
+
+**Note**
+Since the subject-id is specified as single-value attribute, only the first
+value of `identifyingAttribute` and `scopeAttribute` are considered.
+
+Examples
+--------
+
+```php
+    'authproc' => [
+        50 => [
+            'class' => 'subjectidattrs:SubjectID',
+            'identifyingAttribute' => 'uid',
+            'scopeAttribute' => 'scope',
+        ],
+    ],
+```
diff --git a/modules/saml/src/Auth/Process/PairwiseID.php b/modules/saml/src/Auth/Process/PairwiseID.php
new file mode 100644
index 0000000000000000000000000000000000000000..db5d5673cab4b51bd96841228b8eeecee0bdf849
--- /dev/null
+++ b/modules/saml/src/Auth/Process/PairwiseID.php
@@ -0,0 +1,105 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\saml\Auth\Process;
+
+use SAML2\Constants;
+use SimpleSAML\Assert\Assert;
+use SimpleSAML\{Auth, Utils};
+
+/**
+ * Filter to generate the Pairwise ID attribute.
+ *
+ * See: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/csprd01/saml-subject-id-attr-v1.0-csprd01.html
+ *
+ * By default, this filter will generate the ID based on the UserID of the current user.
+ * This is generated from the attribute configured in 'identifyingAttribute' in the
+ * authproc-configuration.
+ *
+ * NOTE: since the subject-id is specified as single-value attribute, only the first value of `identifyingAttribute`
+ *       and `scopeAttribute` are considered.
+ *
+ * Example - generate from attribute:
+ * <code>
+ * 'authproc' => [
+ *   50 => [
+ *       'saml:PairwiseID',
+ *       'identifyingAttribute' => 'uid',
+ *       'scopeAttribute' => 'example.org',
+ *   ]
+ * ]
+ * </code>
+ *
+ * @package SimpleSAMLphp
+ */
+class PairwiseID extends SubjectID
+{
+    /**
+     * The name for this class
+     */
+    public const NAME = 'PairwiseID';
+
+    /**
+     * @var \SimpleSAML\Utils\Config
+     */
+    protected Utils\Config $configUtils;
+
+
+    /**
+     * Initialize this filter.
+     *
+     * @param array &$config  Configuration information about this filter.
+     * @param mixed $reserved  For future use.
+     */
+    public function __construct(array &$config, $reserved)
+    {
+        parent::__construct($config, $reserved);
+
+        $this->configUtils = new Utils\Config();
+    }
+
+
+    /**
+     * Apply filter to add the Pairwise ID.
+     *
+     * @param array &$state  The current state.
+     */
+    public function process(array &$state): void
+    {
+        $userID = $this->getIdentifyingAttribute($state);
+        $scope = $this->getScopeAttribute($state);
+
+        if ($scope === null || $userID === null) {
+            // Attributes missing, precondition not met
+            return;
+        }
+
+        if (!empty($state['saml:RequesterID'])) {
+            // Proxied request - use actual SP entity ID
+            $sp_entityid = $state['saml:RequesterID'][0];
+        } else {
+            $sp_entityid = $state['core:SP'];
+        }
+
+        // Calculate hash
+        $salt = $this->configUtils->getSecretSalt();
+        $hash = hash_hmac('sha256', $userID . '|' . $sp_entityid, $salt, false);
+
+        $value = $hash . '@' . strtolower($scope);
+        $this->validateGeneratedIdentifier($value);
+
+        $state['Attributes'][Constants::ATTR_PAIRWISE_ID] = [$value];
+    }
+
+
+    /**
+     * Inject the \SimpleSAML\Utils\Config dependency.
+     *
+     * @param \SimpleSAML\Utils\Config $configUtils
+     */
+    public function setConfigUtils(Utils\Config $configUtils): void
+    {
+        $this->configUtils = $configUtils;
+    }
+}
diff --git a/modules/saml/src/Auth/Process/SubjectID.php b/modules/saml/src/Auth/Process/SubjectID.php
new file mode 100644
index 0000000000000000000000000000000000000000..9f984e97a9dff8a1a098b81165158d2f275d8eb0
--- /dev/null
+++ b/modules/saml/src/Auth/Process/SubjectID.php
@@ -0,0 +1,232 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\saml\Auth\Process;
+
+use SAML2\Constants;
+use SAML2\Exception\ProtocolViolationException;
+use SimpleSAML\Assert\Assert;
+use SimpleSAML\{Auth, Logger};
+
+/**
+ * Filter to generate the subject ID attribute.
+ *
+ * See: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/csprd01/saml-subject-id-attr-v1.0-csprd01.html
+ *
+ * By default, this filter will generate the ID based on the UserID of the current user.
+ * This is generated from the attribute configured in 'identifyingAttribute' in the
+ * authproc-configuration.
+ *
+ * NOTE: since the subject-id is specified as single-value attribute, only the first value of `identifyingAttribute`
+ *       and `scopeAttribute` are considered.
+ *
+ * Example - generate from attribute:
+ * <code>
+ * 'authproc' => [
+ *   50 => [
+ *       'saml:SubjectID',
+ *       'identifyingAttribute' => 'uid',
+ *       'scopeAttribute' => 'scope',
+ *   ]
+ * ]
+ * </code>
+ *
+ * @package SimpleSAMLphp
+ */
+class SubjectID extends Auth\ProcessingFilter
+{
+    /**
+     * The name for this class
+     */
+    public const NAME = 'SubjectID';
+
+    /**
+     * The regular expression to match the scope
+     *
+     * @var string
+     */
+    public const SCOPE_PATTERN = '/^[a-z0-9][a-z0-9.-]{0,126}$/i';
+
+    /**
+     * The regular expression to match the specifications
+     *
+     * @var string
+     */
+    public const SPEC_PATTERN = '/^[a-z0-9][a-z0-9=-]{0,126}@[a-z0-9][a-z0-9.-]{0,126}$/i';
+
+    /**
+     * The regular expression to match worrisome identifiers that need to raise a warning
+     *
+     * @var string
+     */
+    public const WARN_PATTERN = '/^[a-z0-9][a-z0-9=-]{3,}@[a-z0-9][a-z0-9.-]+\.[a-z]{2,}$/i';
+
+    /**
+     * The attribute we should generate the subject id from.
+     *
+     * @var string
+     */
+    protected string $identifyingAttribute;
+
+    /**
+     * The attribute we should use for the scope of the subject id.
+     *
+     * @var string
+     */
+    protected string $scopeAttribute;
+
+    /**
+     * @var \SimpleSAML\Logger|string
+     * @psalm-var \SimpleSAML\Logger|class-string
+     */
+    protected $logger = Logger::class;
+
+
+    /**
+     * Initialize this filter.
+     *
+     * @param array &$config  Configuration information about this filter.
+     * @param mixed $reserved  For future use.
+     */
+    public function __construct(array &$config, $reserved)
+    {
+        parent::__construct($config, $reserved);
+
+        Assert::keyExists($config, 'identifyingAttribute', "Missing mandatory 'identifyingAttribute' config setting.");
+        Assert::keyExists($config, 'scopeAttribute', "Missing mandatory 'scopeAttribute' config setting.");
+        Assert::stringNotEmpty($config['identifyingAttribute']);
+        Assert::stringNotEmpty($config['scopeAttribute']);
+
+        $this->identifyingAttribute = $config['identifyingAttribute'];
+        $this->scopeAttribute = $config['scopeAttribute'];
+    }
+
+
+    /**
+     * Apply filter to add the subject ID.
+     *
+     * @param array &$state  The current state.
+     */
+    public function process(array &$state): void
+    {
+        $userID = $this->getIdentifyingAttribute($state);
+        $scope = $this->getScopeAttribute($state);
+
+        if ($scope === null || $userID === null) {
+            // Attributes missing, precondition not met
+            return;
+        }
+
+        $value = strtolower($userID . '@' . $scope);
+        $this->validateGeneratedIdentifier($value);
+
+        $state['Attributes'][Constants::ATTR_SUBJECT_ID] = [$value];
+    }
+
+
+    /**
+     * Retrieve the identifying attribute from the state and test it for erroneous conditions
+     *
+     * @param array $state
+     * @return string|null
+     * @throws \SimpleSAML\Assert\AssertionFailedException if the pre-conditions are not met
+     */
+    protected function getIdentifyingAttribute(array $state): ?string
+    {
+        if (
+            !array_key_exists('Attributes', $state)
+            || !array_key_exists($this->identifyingAttribute, $state['Attributes'])
+        ) {
+            $this->logger::warning(
+                sprintf(
+                    "saml:" . static::NAME . ": Missing attribute '%s', which is needed to generate the ID.",
+                    $this->identifyingAttribute
+                )
+            );
+
+            return null;
+        }
+
+        $userID = $state['Attributes'][$this->identifyingAttribute][0];
+        Assert::stringNotEmpty($userID, 'saml:' . static::NAME . ': \'identifyingAttribute\' cannot be an empty string.');
+
+        return $userID;
+    }
+
+
+    /**
+     * Retrieve the scope attribute from the state and test it for erroneous conditions
+     *
+     * @param array $state
+     * @return string|null
+     * @throws \SimpleSAML\Assert\AssertionFailedException if the scope is an empty string
+     * @throws \SAML2\Exception\ProtocolViolationException if the pre-conditions are not met
+     */
+    protected function getScopeAttribute(array $state): ?string
+    {
+        if (!array_key_exists('Attributes', $state) || !array_key_exists($this->scopeAttribute, $state['Attributes'])) {
+            $this->logger::warning(
+                sprintf(
+                    "saml:" . static::NAME . ": Missing attribute '%s', which is needed to generate the ID.",
+                    $this->scopeAttribute
+                )
+            );
+
+            return null;
+        }
+
+        $scope = $state['Attributes'][$this->scopeAttribute][0];
+        Assert::stringNotEmpty($scope, 'saml:' . static::NAME . ': \'scopeAttribute\' cannot be an empty string.');
+
+        // If the value is scoped, extract the scope from it
+        if (strpos($scope, '@') !== false) {
+            $scope = explode('@', $scope, 2);
+            $scope = $scope[1];
+        }
+
+        Assert::regex(
+            $scope,
+            self::SCOPE_PATTERN,
+            'saml:' . static::NAME . ': \'scopeAttribute\' contains illegal characters.',
+            ProtocolViolationException::class
+        );
+        return $scope;
+    }
+
+
+    /**
+     * Test the generated identifier to ensure compliancy with the specifications.
+     * Log a warning when the generated value is considered to be weak
+     *
+     * @param string $value
+     * @return void
+     * @throws \SAML2\Exception\ProtocolViolationException if the post-conditions are not met
+     */
+    protected function validateGeneratedIdentifier(string $value): void
+    {
+        Assert::regex(
+            $value,
+            self::SPEC_PATTERN,
+            'saml:' . static::NAME . ': Generated ID \'' . $value . '\' contains illegal characters.',
+            ProtocolViolationException::class
+        );
+
+        if (preg_match(self::WARN_PATTERN, $value) === 0) {
+            $this->logger::warning(
+                'saml:' . static::NAME . ': Generated ID \'' . $value . '\' can hardly be considered globally unique.'
+            );
+        }
+    }
+
+
+    /**
+     * Inject the \SimpleSAML\Logger dependency.
+     *
+     * @param \SimpleSAML\Logger $logger
+     */
+    public function setLogger(Logger $logger): void
+    {
+        $this->logger = $logger;
+    }
+}
diff --git a/tests/modules/saml/src/Auth/Process/PairwiseIDTest.php b/tests/modules/saml/src/Auth/Process/PairwiseIDTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..911c3311f40ea9fc7306dcb26ea6e48d0da5e9cc
--- /dev/null
+++ b/tests/modules/saml/src/Auth/Process/PairwiseIDTest.php
@@ -0,0 +1,340 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\saml\Auth\Process;
+
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+use SAML2\Constants;
+use SAML2\Exception\ProtocolViolationException;
+use SimpleSAML\Assert\AssertionFailedException;
+use SimpleSAML\{Configuration, Logger, Utils};
+use SimpleSAML\Module\saml\Auth\Process\PairwiseID;
+
+/**
+ * Test for the saml:PairwiseID filter.
+ *
+ * @covers \SimpleSAML\Module\saml\Auth\Process\PairwiseID
+ */
+class PairwiseIDTest extends TestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+    /** @var \SimpleSAML\Utils\Config */
+    protected static Utils\Config $configUtils;
+
+    /** @var \SimpleSAML\Logger */
+    protected static Logger $logger;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        self::$configUtils = new class () extends Utils\Config {
+            public function getSecretSalt(): string
+            {
+                // stub
+                return 'secretsalt';
+            }
+        };
+
+        self::$logger = new class () extends Logger {
+            public static function warning(string $string): void
+            {
+                // stub
+                throw new RuntimeException($string);
+            }
+        };
+    }
+
+
+    /**
+     * Helper function to run the filter with a given configuration.
+     *
+     * @param array $config  The filter configuration.
+     * @param array $request  The request state.
+     * @return array  The state array after processing.
+     */
+    private static function processFilter(array $config, array $request): array
+    {
+        $filter = new PairwiseID($config, null);
+        $filter->setConfigUtils(self::$configUtils);
+        $filter->setLogger(self::$logger);
+        $filter->process($request);
+        return $request;
+    }
+
+
+    /**
+     * Test the most basic functionality
+     */
+    public function testBasic(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']],
+            'core:SP' => 'urn:sp',
+        ];
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $this->assertMatchesRegularExpression(
+            PairwiseID::SPEC_PATTERN,
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+        $this->assertEquals(
+            'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org',
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+    }
+
+
+    /**
+     * Test the most basic functionality, but with a scoped scope-attribute
+     */
+    public function testBasicScopedScope(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['u=se-r2@ex-ample.org']],
+            'core:SP' => 'urn:sp',
+        ];
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $this->assertMatchesRegularExpression(
+            PairwiseID::SPEC_PATTERN,
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+        $this->assertEquals(
+            'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org',
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+    }
+
+
+    /**
+     * Test the most basic functionality on proxied request
+     */
+    public function testBasicProxiedRequest(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']],
+            'saml:RequesterID' => [0 => 'urn:sp'],
+        ];
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $this->assertMatchesRegularExpression(
+            PairwiseID::SPEC_PATTERN,
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+        $this->assertEquals(
+            'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org',
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+    }
+
+
+    /**
+     * Test the proxied request with multiple hops
+     */
+    public function testProxiedRequestMultipleHops(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']],
+            'saml:RequesterID' => [0 => 'urn:sp', 1 => 'urn:some:sp', 2 => 'urn:some:other:sp'],
+        ];
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $this->assertMatchesRegularExpression(
+            PairwiseID::SPEC_PATTERN,
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+        $this->assertEquals(
+            'c22d58bebef42e50e203d0e932ae4a7f560a51d494266990a5b5c73f34b1854e@ex-ample.org',
+            $attributes[Constants::ATTR_PAIRWISE_ID][0]
+        );
+    }
+
+
+    /**
+     * Test that illegal characters in scope throws an exception.
+     */
+    public function testScopeIllegalCharacterThrowsException(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user2'], 'scope' => ['ex%ample.org']],
+            'core:SP' => 'urn:sp',
+        ];
+
+        $this->expectException(ProtocolViolationException::class);
+        self::processFilter($config, $request);
+    }
+
+
+    /**
+     * Test that generated ID's for the same user, but different SP's are NOT equal
+     */
+    public function testUniqueIdentifierPerSPSameUser(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']],
+            'core:SP' => 'urn:sp',
+        ];
+
+        // Generate first ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        // Switch SP
+        $request['core:SP'] = 'urn:some:other:sp';
+
+        // Generate second ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        $this->assertNotSame($value1, $value2);
+    }
+
+
+    /**
+     * Test that generated ID's for different users, but the same SP's are NOT equal
+     */
+    public function testUniqueIdentifierPerUserSameSP(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']],
+            'core:SP' => 'urn:sp',
+        ];
+
+        // Generate first ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        // Switch user
+        $request['Attributes']['uid'] = ['user2'];
+
+        // Generate second ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        $this->assertNotSame($value1, $value2);
+    }
+
+
+    /**
+     * Test that generated ID's for the same user and same SP, but with a different salt are NOT equal
+     */
+    public function testUniqueIdentifierDifferentSalts(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']],
+            'core:SP' => 'urn:sp',
+        ];
+
+        // Generate first ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        // Change the salt
+        self::$configUtils = new class () extends Utils\Config {
+            public function getSecretSalt(): string
+            {
+                // stub
+                return 'pepper';
+            }
+        };
+
+        // Generate second ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        $this->assertNotSame($value1, $value2);
+    }
+
+
+    /**
+     * Test that generated ID's for the same user and same SP, but with a different scope are NOT equal
+     */
+    public function testUniqueIdentifierDifferentScopes(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']],
+            'core:SP' => 'urn:sp',
+        ];
+
+        // Generate first ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        // Change the scope
+        $request['Attributes']['scope'] = ['example.edu'];
+
+        // Generate second ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
+        $value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
+
+        $this->assertNotSame($value1, $value2);
+
+        $this->assertMatchesRegularExpression(
+            '/@example.org$/i',
+            $value1
+        );
+        $this->assertMatchesRegularExpression(
+            '/@example.edu$/i',
+            $value2
+        );
+    }
+
+
+    /**
+     * Test that weak identifiers log a warning
+     */
+    public function testWeakIdentifierLogsWarning(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['a'], 'scope' => ['b']],
+            'core:SP' => 'urn:sp',
+        ];
+
+        $expected = 'be511fc7f95e22816dbac21e3b70546660963b6e9b85f5a41d80bfc6baadd547@b';
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage(
+            'saml:PairwiseID: Generated ID \'' . $expected . '\' can hardly be considered globally unique.'
+        );
+
+        self::processFilter($config, $request);
+    }
+}
diff --git a/tests/modules/saml/src/Auth/Process/SubjectIDTest.php b/tests/modules/saml/src/Auth/Process/SubjectIDTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2a515d6e2ffc00caab3a55f1457a0d653d7eef04
--- /dev/null
+++ b/tests/modules/saml/src/Auth/Process/SubjectIDTest.php
@@ -0,0 +1,230 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\saml\Auth\Process;
+
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+use SAML2\Constants;
+use SAML2\Exception\ProtocolViolationException;
+use SimpleSAML\Assert\AssertionFailedException;
+use SimpleSAML\{Configuration, Logger, Utils};
+use SimpleSAML\Module\saml\Auth\Process\SubjectID;
+
+/**
+ * Test for the saml:SubjectID filter.
+ *
+ * @covers \SimpleSAML\Module\saml\Auth\Process\SubjectID
+ */
+class SubjectIDTest extends TestCase
+{
+    /** @var \SimpleSAML\Configuration */
+    protected Configuration $config;
+
+    /** @var \SimpleSAML\Logger */
+    protected static Logger $logger;
+
+
+    /**
+     * Set up for each test.
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        self::$logger = new class () extends Logger {
+            public static function warning(string $string): void
+            {
+                // stub
+                throw new RuntimeException($string);
+            }
+        };
+    }
+
+
+    /**
+     * Helper function to run the filter with a given configuration.
+     *
+     * @param array $config  The filter configuration.
+     * @param array $request  The request state.
+     * @return array  The state array after processing.
+     */
+    private static function processFilter(array $config, array $request): array
+    {
+        $filter = new SubjectID($config, null);
+        $filter->setLogger(self::$logger);
+        $filter->process($request);
+        return $request;
+    }
+
+
+    /**
+     * Test the most basic functionality
+     */
+    public function testBasic(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['ex-ample.org']],
+        ];
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
+        $this->assertMatchesRegularExpression(
+            SubjectID::SPEC_PATTERN,
+            $attributes[Constants::ATTR_SUBJECT_ID][0]
+        );
+        $this->assertEquals('u=se-r2@ex-ample.org', $attributes[Constants::ATTR_SUBJECT_ID][0]);
+    }
+
+
+    /**
+     * Test the most basic functionality, but with a scoped scope-attribute
+     */
+    public function testScopedScope(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['u=se-r2'], 'scope' => ['u=se-r2@ex-ample.org']],
+        ];
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
+        $this->assertMatchesRegularExpression(
+            SubjectID::SPEC_PATTERN,
+            $attributes[Constants::ATTR_SUBJECT_ID][0]
+        );
+        $this->assertEquals('u=se-r2@ex-ample.org', $attributes[Constants::ATTR_SUBJECT_ID][0]);
+    }
+
+
+    /**
+     * Test that illegal characters in userID throws an exception.
+     */
+    public function testUserIDIllegalCharacterThrowsException(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['u=se+r2'], 'scope' => ['example.org']],
+        ];
+
+        $this->expectException(ProtocolViolationException::class);
+        self::processFilter($config, $request);
+    }
+
+
+    /**
+     * Test that illegal characters in scope throws an exception.
+     */
+    public function testScopeIllegalCharacterThrowsException(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user2'], 'scope' => ['ex%ample.org']],
+        ];
+
+        $this->expectException(ProtocolViolationException::class);
+        self::processFilter($config, $request);
+    }
+
+
+    /**
+     * Test that generated ID's for different users, but the same SP's are NOT equal
+     */
+    public function testUniqueIdentifierPerUserSameSP(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']],
+        ];
+
+        // Generate first ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
+        $value1 = $attributes[Constants::ATTR_SUBJECT_ID][0];
+
+        // Switch user
+        $request['Attributes']['uid'] = ['user2'];
+
+        // Generate second ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
+        $value2 = $attributes[Constants::ATTR_SUBJECT_ID][0];
+
+        $this->assertNotSame($value1, $value2);
+    }
+
+
+    /**
+     * Test that generated ID's for the same user and same SP, but with a different scope are NOT equal
+     */
+    public function testUniqueIdentifierDifferentScopes(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['user1'], 'scope' => ['example.org']],
+        ];
+
+        // Generate first ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
+        $value1 = $attributes[Constants::ATTR_SUBJECT_ID][0];
+
+        // Change the scope
+        $request['Attributes']['scope'] = ['example.edu'];
+
+        // Generate second ID
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
+        $value2 = $attributes[Constants::ATTR_SUBJECT_ID][0];
+
+        $this->assertNotSame($value1, $value2);
+
+        $this->assertMatchesRegularExpression(
+            '/@example.org$/i',
+            $value1
+        );
+        $this->assertMatchesRegularExpression(
+            '/@example.edu$/i',
+            $value2
+        );
+    }
+
+
+    /**
+     * Test that weak identifiers log a warning
+     */
+    public function testWeakIdentifierLogsWarning(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['a'], 'scope' => ['b']],
+        ];
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('saml:SubjectID: Generated ID \'a@b\' can hardly be considered globally unique.');
+
+        self::processFilter($config, $request);
+    }
+
+    /**
+     * Test that weak identifiers log a warning: not an actual domain name
+     */
+    public function testScopeNotADomainLogsWarning(): void
+    {
+        $config = ['identifyingAttribute' => 'uid', 'scopeAttribute' => 'scope'];
+        $request = [
+            'Attributes' => ['uid' => ['a1398u9u25'], 'scope' => ['example']],
+        ];
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('saml:SubjectID: Generated ID \'a1398u9u25@example\' can hardly be considered globally unique.');
+
+        self::processFilter($config, $request);
+    }
+}