diff --git a/modules/core/dictionaries/cardinality.definition.json b/modules/core/dictionaries/cardinality.definition.json
new file mode 100644
index 0000000000000000000000000000000000000000..b9059cb93a389ad4422be193ae8b17371ed426cf
--- /dev/null
+++ b/modules/core/dictionaries/cardinality.definition.json
@@ -0,0 +1,14 @@
+{
+    "cardinality_header": {
+        "en": "Incorrect Attributes"
+    },
+    "cardinality_text": {
+        "en": "One or more of the attributes supplied by your identity provider did not contain the expected number of values."
+    },
+    "problematic_attributes": {
+        "en": "The problematic attribute(s) are:"
+    },
+    "got_want": {
+        "en": "got %GOT% values, want %WANT%"
+    }
+}
diff --git a/modules/core/dictionaries/cardinality.translation.json b/modules/core/dictionaries/cardinality.translation.json
new file mode 100644
index 0000000000000000000000000000000000000000..d54ab371fbefc1cec8a11e65efae46e0e1996980
--- /dev/null
+++ b/modules/core/dictionaries/cardinality.translation.json
@@ -0,0 +1,15 @@
+{
+    "cardinality_header": {
+        "af": "Onjuiste Eienskappe",
+        "nl": "Niet de juiste attributen"
+    },
+    "cardinality_text": {
+        "nl": "Één of meer door de Identity Provider geleverde attributen bevat niet het vereiste aantal attributen."
+    },
+    "problematic_attributes": {
+        "nl": "De onjuiste attributen zijn:"
+    },
+    "got_want": {
+        "nl": "%GOT% ontvangen waarden, %WANT% vereist"
+    }
+}
diff --git a/modules/core/docs/authproc_cardinality.md b/modules/core/docs/authproc_cardinality.md
new file mode 100644
index 0000000000000000000000000000000000000000..0902bf27863dad94991aacb54d15a7a407750f63
--- /dev/null
+++ b/modules/core/docs/authproc_cardinality.md
@@ -0,0 +1,47 @@
+`core:Cardinality`
+==================
+
+Ensure the number of attribute values is within the specified multiplicity.
+
+This filter should contain a set of attribute name => rule pairs describing the multiplicity rules for an attribute.
+
+The special parameter `%ignoreEntities` can be used to give an array of entity IDs that should be ignored for testing, etc purposes.
+
+Specifying Rules
+----------------
+
+Multiplicity rules are specified as an associative array containing one or more of the following parameters:
+
+`min`
+:   The minimum number of values (participation) this attribute should have. Defaults to `zero`.
+
+`max`
+:   The maximum number of values (cardinality) this attribute should have. Defaults to no upper bound.
+
+`warn`
+:   Log a warning rather than generating an error. Defaults to `false`.
+
+For convenience, minimum and maximum values can also be specified using a shorthand list notation.
+
+Examples
+--------
+
+Require at least one `givenName`, no more than two email addresses, and between two and four values for `eduPersonScopedAffiliation`.
+
+    'authproc' => array(
+        50 => array(
+            'class' => 'core:Cardinality',
+            'givenName' => array('min' => 1),
+            'mail' => array('max' => 2),
+            'eduPersonScopedAffiliation' => array('min' => 2, 'max' => 4),
+        ),
+    ),
+
+Use the shorthand notation for min, max:
+
+    'authproc' => array(
+        50 => array(
+            'class' => 'core:Cardinality',
+            'mail' => array(0, 2),
+        ),
+    ),
diff --git a/modules/core/docs/authproc_cardinalitysingle.md b/modules/core/docs/authproc_cardinalitysingle.md
new file mode 100644
index 0000000000000000000000000000000000000000..4a61bdbc5aaca4579729fc0bafec6b6976a2efea
--- /dev/null
+++ b/modules/core/docs/authproc_cardinalitysingle.md
@@ -0,0 +1,88 @@
+`core:CardinalitySingle`
+========================
+
+Ensure the correct cardinality of single-valued attributes. This filter is a special case
+of the more generic [core:Cardinality] filter that allows for optional corrective measures
+when multi-valued attributes are received where single-valued ones are expected.
+
+Parameters
+----------
+
+This filter implements a number of optional parameters:
+
+`singleValued`
+:   array of attribute names that *must* be single-valued, or a 403 error is generated.
+
+`firstValue`
+:   array of attribute names where only the first value of a multi-valued assertion should be returned.
+
+`flatten`
+:   array of attribute names where a multi-valued assertion is flattened into a single delimited string.
+
+`flattenWith`
+:   the delimiter for `flatten`. Defaults to ";".
+
+`ignoreEntities`
+:   array of entity IDs that should be ignored for testing, etc purposes.
+
+When the same attribute name appears in multiple stanzas, they are processed in the order above.
+
+Examples
+--------
+
+Abort with an error if any attribute defined as single-valued in the eduPerson or SCHAC schemas exists and has more than one value:
+
+    'authproc' => array(
+        50 => array(
+            'class' => 'core:CardinalitySingle',
+            'singleValued' => array(
+                /* from eduPerson (internet2-mace-dir-eduperson-201602) */
+                'eduPersonOrgDN', 'eduPersonPrimaryAffiliation', 'eduPersonPrimaryOrgUnitDN',
+                'eduPersonPrincipalName', 'eduPersonUniqueId',
+                /* from inetOrgPerson (RFC2798), referenced by internet2-mace-dir-eduperson-201602 */
+                'displayName', 'preferredLanguage',
+                /* from SCHAC-IAD Version 1.3.0 */
+                'schacMotherTongue', 'schacGender', 'schacDateOfBirth', 'schacPlaceOfBirth',
+                'schacPersonalTitle', 'schacHomeOrganization', 'schacHomeOrganizationType',
+                'schacExpiryDate',
+            ),
+        ),
+    ),
+
+Abort if multiple values are received for `eduPersonPrincipalName`, but take the first value for `eduPersonPrimaryAffiliation`:
+
+    'authproc' => array(
+        50 => array(
+            'class' => 'core:CardinalitySingle',
+            'singleValued' => array('eduPersonPrincipalName'),
+            'firstValue' => array('eduPersonPrimaryAffiliation'),
+            ),
+        ),
+    ),
+
+Construct `eduPersonPrimaryAffiliation` using the first value in `eduPersonAffiliation`:
+
+    'authproc' => array(
+        50 => array(
+            'class' => 'core:AttributeCopy',
+            'eduPersonAffiliation' => 'eduPersonPrimaryAffiliation',
+        ),
+        51 => array(
+            'class' => 'core:CardinalitySingle',
+            'firstValue' => array('eduPersonPrimaryAffiliation'),
+        ),
+    ),
+
+Construct a single, comma-separated value version of `eduPersonAffiliation`:
+
+    'authproc' => array(
+        50 => array(
+            'class' => 'core:AttributeCopy',
+            'eduPersonAffiliation' => 'eduPersonAffiliationWithCommas',
+        ),
+        51 => array(
+            'class' => 'core:CardinalitySingle',
+            'flatten' => array('eduPersonAffiliationWithCommas'),
+			'flattenWith' => ',',
+        ),
+    ),
diff --git a/modules/core/lib/Auth/Process/Cardinality.php b/modules/core/lib/Auth/Process/Cardinality.php
new file mode 100644
index 0000000000000000000000000000000000000000..74d330f00c7f1f7526eb2ad45ffa61df1171e223
--- /dev/null
+++ b/modules/core/lib/Auth/Process/Cardinality.php
@@ -0,0 +1,164 @@
+<?php
+
+/**
+ * Filter to ensure correct cardinality of attributes
+ *
+ * @author Guy Halse, http://orcid.org/0000-0002-9388-8592
+ * @package SimpleSAMLphp
+ */
+class sspmod_core_Auth_Process_Cardinality extends SimpleSAML_Auth_ProcessingFilter
+{
+    /** @var array Associative array with the mappings of attribute names. */
+    private $cardinality = array();
+
+    /** @var array Entities that should be ignored */
+    private $ignoreEntities = array();
+
+    /**
+     * Initialize this filter, parse configuration.
+     *
+     * @param array $config  Configuration information about this filter.
+     * @param mixed $reserved  For future use.
+     * @throws SimpleSAML_Error_Exception
+     */
+    public function __construct($config, $reserved)
+    {
+        parent::__construct($config, $reserved);
+        assert(is_array($config));
+
+        foreach ($config as $attribute => $rules) {
+            if ($attribute === '%ignoreEntities') {
+                $this->ignoreEntities = $config['%ignoreEntities'];
+                continue;
+            }
+
+            if (!is_string($attribute)) {
+                throw new SimpleSAML_Error_Exception('Invalid attribute name: '.var_export($attribute, true));
+            }
+            $this->cardinality[$attribute] = array('warn' => false);
+
+            /* allow either positional or name-based parameters */
+            if (isset($rules[0])) {
+                $this->cardinality[$attribute]['min'] = $rules[0];
+            } elseif (isset($rules['min'])) {
+                $this->cardinality[$attribute]['min'] = $rules['min'];
+            }
+            if (isset($rules[1])) {
+                $this->cardinality[$attribute]['max'] = $rules[1];
+            } elseif (isset($rules['max'])) {
+                $this->cardinality[$attribute]['max'] = $rules['max'];
+            }
+            if (array_key_exists('warn', $rules)) {
+                $this->cardinality[$attribute]['warn'] = (bool) $rules['warn'];
+            }
+
+            /* sanity check the rules */
+            if (!array_key_exists('min', $this->cardinality[$attribute])) {
+                $this->cardinality[$attribute]['min'] = 0;
+            } elseif (!is_int($this->cardinality[$attribute]['min']) ||
+                $this->cardinality[$attribute]['min'] < 0
+            ) {
+                throw new SimpleSAML_Error_Exception('Minimum cardinality must be a positive integer: '.
+                    var_export($attribute, true));
+            }
+            if (array_key_exists('max', $this->cardinality[$attribute]) &&
+                !is_int($this->cardinality[$attribute]['max'])
+            ) {
+                throw new SimpleSAML_Error_Exception('Maximum cardinality must be a positive integer: '.
+                    var_export($attribute, true));
+            }
+            if (array_key_exists('min', $this->cardinality[$attribute]) &&
+                array_key_exists('max', $this->cardinality[$attribute]) &&
+                $this->cardinality[$attribute]['min'] > $this->cardinality[$attribute]['max']
+            ) {
+                throw new SimpleSAML_Error_Exception('Minimum cardinality must be less than maximium: '.
+                    var_export($attribute, true));
+            }
+
+            /* generate a display expression */
+            $this->cardinality[$attribute]['_expr'] = sprintf('%d ≤ n', $this->cardinality[$attribute]['min']);
+            if (array_key_exists('max', $this->cardinality[$attribute])) {
+                $this->cardinality[$attribute]['_expr'] .= sprintf(' ≤ %d', $this->cardinality[$attribute]['max']);
+            }
+        }
+    }
+
+    /**
+     * Process this filter
+     *
+     * @param array &$request  The current request
+     */
+    public function process(&$request)
+    {
+        assert(is_array($request));
+        assert(array_key_exists("Attributes", $request));
+
+        $entityid = false;
+        if (array_key_exists('Source', $request) && array_key_exists('entityid', $request['Source'])) {
+            $entityid = $request['Source']['entityid'];
+        }
+        if (in_array($entityid, $this->ignoreEntities)) {
+            SimpleSAML\Logger::debug('Cardinality: Ignoring assertions from '.$entityid);
+            return;
+        }
+
+        foreach ($request['Attributes'] as $k => $v) {
+
+            if (!array_key_exists($k, $this->cardinality)) {
+                continue;
+            }
+            if (!is_array($v)) {
+                $v = array($v);
+            }
+
+            /* minimum cardinality */
+            if (count($v) < $this->cardinality[$k]['min']) {
+                if ($this->cardinality[$k]['warn']) {
+                    SimpleSAML\Logger::warning(sprintf(
+                        'Cardinality: attribute %s from %s does not meet minimum cardinality of %d (%d)',
+                        $k, $entityid, $this->cardinality[$k]['min'], count($v)
+                    ));
+                } else {
+                    $request['core:cardinality:errorAttributes'][$k] = array(count($v), $this->cardinality[$k]['_expr']);
+                }
+                continue;
+            }
+
+            /* maximum cardinality */
+            if (array_key_exists('max', $this->cardinality[$k]) && count($v) > $this->cardinality[$k]['max']) {
+                if ($this->cardinality[$k]['warn']) {
+                    SimpleSAML\Logger::warning(sprintf(
+                        'Cardinality: attribute %s from %s does not meet maximum cardinality of %d (%d)',
+                        $k, $entityid, $this->cardinality[$k]['max'], count($v)
+                    ));
+                } else {
+                    $request['core:cardinality:errorAttributes'][$k] = array(count($v), $this->cardinality[$k]['_expr']);
+                }
+                continue;
+            }
+        }
+
+        /* check for missing attributes with a minimum cardinality */
+        foreach ($this->cardinality as $k => $v) {
+            if (!$this->cardinality[$k]['min'] || array_key_exists($k, $request['Attributes'])) {
+                continue;
+            }
+            if ($this->cardinality[$k]['warn']) {
+                SimpleSAML\Logger::warning(sprintf(
+                    'Cardinality: attribute %s from %s is missing',
+                    $k, $entityid
+                ));
+            } else {
+                $request['core:cardinality:errorAttributes'][$k] = array(0, $this->cardinality[$k]['_expr']);
+            }
+        }
+
+        /* abort if we found a problematic attribute */
+        if (array_key_exists('core:cardinality:errorAttributes', $request)) {
+            $id = SimpleSAML_Auth_State::saveState($request, 'core:cardinality');
+            $url = SimpleSAML\Module::getModuleURL('core/cardinality_error.php');
+            \SimpleSAML\Utils\HTTP::redirectTrustedURL($url, array('StateId' => $id));
+            return;
+        }
+    }
+}
diff --git a/modules/core/lib/Auth/Process/CardinalitySingle.php b/modules/core/lib/Auth/Process/CardinalitySingle.php
new file mode 100644
index 0000000000000000000000000000000000000000..459d57d8ba162893bcd20fa4f5e1585d43ac5731
--- /dev/null
+++ b/modules/core/lib/Auth/Process/CardinalitySingle.php
@@ -0,0 +1,109 @@
+<?php
+
+/**
+ * Filter to ensure correct cardinality of single-valued attributes
+ *
+ * This filter implements a special case of the core:Cardinality filter, and
+ * allows for optional corrections to be made when cardinality errors are encountered.
+ *
+ * @author Guy Halse, http://orcid.org/0000-0002-9388-8592
+ * @package SimpleSAMLphp
+ */
+class sspmod_core_Auth_Process_CardinalitySingle extends SimpleSAML_Auth_ProcessingFilter
+{
+    /** @var array Attributes that should be single-valued or we generate an error */
+    private $singleValued = array();
+
+    /** @var array Attributes for which the first value should be taken */
+    private $firstValue = array();
+
+    /** @var array Attributes that can be flattened to a single value */
+    private $flatten = array();
+
+    /** @var string Separator for flattened value */
+    private $flattenWith = ';';
+
+    /** @var array Entities that should be ignored */
+    private $ignoreEntities = array();
+
+    /**
+     * Initialize this filter, parse configuration.
+     *
+     * @param array $config  Configuration information about this filter.
+     * @param mixed $reserved  For future use.
+     */
+    public function __construct($config, $reserved)
+    {
+        parent::__construct($config, $reserved);
+        assert(is_array($config));
+
+        if (array_key_exists('singleValued', $config)) {
+            $this->singleValued = $config['singleValued'];
+        }
+        if (array_key_exists('firstValue', $config)) {
+            $this->firstValue = $config['firstValue'];
+        }
+        if (array_key_exists('flattenWith', $config)) {
+            $this->flattenWith = is_array($config['flattenWith']) ? array_shift($config['flattenWith']) : $config['flattenWith'];
+        }
+        if (array_key_exists('flatten', $config)) {
+            $this->flatten = $config['flatten'];
+        }
+        if (array_key_exists('ignoreEntities', $config)) {
+            $this->ignoreEntities = $config['ignoreEntities'];
+        }
+        /* for consistency with core:Cardinality */
+        if (array_key_exists('%ignoreEntities', $config)) {
+            $this->ignoreEntities = $config['%ignoreEntities'];
+        }
+    }
+
+    /**
+     * Process this filter
+     *
+     * @param array &$request  The current request
+     */
+    public function process(&$request)
+    {
+        assert(is_array($request));
+        assert(array_key_exists("Attributes", $request));
+
+        if (array_key_exists('Source', $request) &&
+            array_key_exists('entityid', $request['Source']) &&
+            in_array($request['Source']['entityid'], $this->ignoreEntities)
+        ) {
+            SimpleSAML\Logger::debug('CardinalitySingle: Ignoring assertions from '.$request['Source']['entityid']);
+            return;
+        }
+
+        foreach ($request['Attributes'] as $k => $v) {
+            if (!is_array($v)) {
+                continue;
+            }
+            if (count($v) <= 1) {
+                continue;
+            }
+
+            if (in_array($k, $this->singleValued)) {
+                $request['core:cardinality:errorAttributes'][$k] = array(count($v), '0 ≤ n ≤ 1');
+                continue;
+            }
+            if (in_array($k, $this->firstValue)) {
+                $request['Attributes'][$k] = array(array_shift($v));
+                continue;
+            }
+            if (in_array($k, $this->flatten)) {
+                $request['Attributes'][$k] = array(implode($this->flattenWith, $v));
+                continue;
+            }
+        }
+
+        /* abort if we found a problematic attribute */
+        if (array_key_exists('core:cardinality:errorAttributes', $request)) {
+            $id = SimpleSAML_Auth_State::saveState($request, 'core:cardinality');
+            $url = SimpleSAML\Module::getModuleURL('core/cardinality_error.php');
+            \SimpleSAML\Utils\HTTP::redirectTrustedURL($url, array('StateId' => $id));
+            return;
+        }
+    }
+}
diff --git a/modules/core/templates/cardinality_error.tpl.php b/modules/core/templates/cardinality_error.tpl.php
new file mode 100644
index 0000000000000000000000000000000000000000..dc75c540afc9b7f71a167a5d397188eca3cb3e47
--- /dev/null
+++ b/modules/core/templates/cardinality_error.tpl.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Template which is shown when when an attribute violates a cardinality rule
+ *
+ * Parameters:
+ * - 'target': Target URL.
+ * - 'params': Parameters which should be included in the request.
+ *
+ * @package SimpleSAMLphp
+ */
+
+
+$this->data['cardinality_header'] = $this->t('{core:cardinality:cardinality_header}');
+$this->data['cardinality_text'] = $this->t('{core:cardinality:cardinality_text}');
+$this->data['problematic_attributes'] = $this->t('{core:cardinality:problematic_attributes}');
+
+$this->includeAtTemplateBase('includes/header.php');
+?>
+<h1><?php echo $this->data['cardinality_header']; ?></h1>
+<p><?php echo $this->data['cardinality_text']; ?></p>
+<h3><?php echo $this->data['problematic_attributes']; ?></h3>
+<dl class="cardinalityErrorAttributes">
+<?php foreach ($this->data['cardinalityErrorAttributes'] as $attr => $v) { ?>
+        <dt><?php echo $attr ?></td>
+        <dd><?php echo $this->t('{core:cardinality:got_want}', array('%GOT%' => $v[0], '%WANT%' => htmlspecialchars($v[1]))) ?></dd>
+    </tr>
+<?php } ?>
+</dl>
+<?php
+if (isset($this->data['LogoutURL'])) {
+?>
+<p><a href="<?php echo htmlspecialchars($this->data['LogoutURL']); ?>"><?php echo $this->t('{status:logout}'); ?></a></p>
+<?php
+}
+?>
+<?php
+$this->includeAtTemplateBase('includes/footer.php');
diff --git a/modules/core/www/cardinality_error.php b/modules/core/www/cardinality_error.php
new file mode 100644
index 0000000000000000000000000000000000000000..4b36495fb859baa797ce67b11eb3660aca336b25
--- /dev/null
+++ b/modules/core/www/cardinality_error.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Show a 403 Forbidden page when an attribute violates a cardinality rule
+ *
+ * @package SimpleSAMLphp
+ */
+
+if (!array_key_exists('StateId', $_REQUEST)) {
+    throw new \SimpleSAML_Error_BadRequest('Missing required StateId query parameter.');
+}
+$id = $_REQUEST['StateId'];
+$state = \SimpleSAML_Auth_State::loadState($id, 'core:cardinality');
+$session = \SimpleSAML_Session::getSessionFromRequest();
+
+\SimpleSAML\Logger::stats('core:cardinality:error '.$state['Destination']['entityid'].' '.$state['saml:sp:IdP'].
+    ' '.implode(',', array_keys($state['core:cardinality:errorAttributes'])));
+
+$globalConfig = SimpleSAML_Configuration::getInstance();
+$t = new \SimpleSAML_XHTML_Template($globalConfig, 'core:cardinality_error.tpl.php');
+$t->data['cardinalityErrorAttributes'] = $state['core:cardinality:errorAttributes'];
+if (isset($state['Source']['auth'])) {
+    $t->data['LogoutURL'] = \SimpleSAML\Module::getModuleURL('core/authenticate.php', array('as' => $state['Source']['auth']))."&logout";
+}
+header('HTTP/1.0 403 Forbidden');
+$t->show();
diff --git a/tests/modules/core/lib/Auth/Process/CardinalitySingleTest.php b/tests/modules/core/lib/Auth/Process/CardinalitySingleTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ccc03bf7c7bbabc640afe0febaf30af2c5a88888
--- /dev/null
+++ b/tests/modules/core/lib/Auth/Process/CardinalitySingleTest.php
@@ -0,0 +1,141 @@
+<?php
+// Alias the PHPUnit 6.0 ancestor if available, else fall back to legacy ancestor
+if (class_exists('\PHPUnit\Framework\TestCase', true) and !class_exists('\PHPUnit_Framework_TestCase', true)) {
+    class_alias('\PHPUnit\Framework\TestCase', '\PHPUnit_Framework_TestCase', true);
+}
+
+/**
+ * Test for the core:CardinalitySingle filter.
+ */
+class Test_Core_Auth_Process_CardinalitySingleTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * 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)
+    {
+        $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1';
+        $_SERVER['REQUEST_METHOD'] = 'GET';
+        $filter = new sspmod_core_Auth_Process_CardinalitySingle($config, null);
+        $filter->process($request);
+        return $request;
+    }
+
+    protected function setUp()
+    {
+        \SimpleSAML_Configuration::loadFromArray(array(), '[ARRAY]', 'simplesaml');
+    }
+
+    /**
+     * Test singleValued
+     */
+    public function testSingleValuedUnchanged()
+    {
+        $config = array(
+            'singleValued' => array('eduPersonPrincipalName')
+        );
+        $request = array(
+            'Attributes' => array(
+                'eduPersonPrincipalName' => array('joe@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('eduPersonPrincipalName' => array('joe@example.com'));
+        $this->assertEquals($expectedData, $attributes, "Assertion values should not have changed");
+    }
+
+    /**
+     * Test first value extraction
+     */
+    public function testFirstValue()
+    {
+        $config = array(
+            'firstValue' => array('eduPersonPrincipalName')
+        );
+        $request = array(
+            'Attributes' => array(
+                'eduPersonPrincipalName' => array('joe@example.com', 'bob@example.net'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('eduPersonPrincipalName' => array('joe@example.com'));
+        $this->assertEquals($expectedData, $attributes, "Only first value should be returned");
+    }
+
+    public function testFirstValueUnchanged()
+    {
+        $config = array(
+            'firstValue' => array('eduPersonPrincipalName')
+        );
+        $request = array(
+            'Attributes' => array(
+                'eduPersonPrincipalName' => array('joe@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('eduPersonPrincipalName' => array('joe@example.com'));
+        $this->assertEquals($expectedData, $attributes, "Assertion values should not have changed");
+    }
+
+    /**
+     * Test flattening
+     */
+    public function testFlatten()
+    {
+        $config = array(
+            'flatten' => array('eduPersonPrincipalName'),
+            'flattenWith' => '|',
+        );
+        $request = array(
+            'Attributes' => array(
+                'eduPersonPrincipalName' => array('joe@example.com', 'bob@example.net'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('eduPersonPrincipalName' => array('joe@example.com|bob@example.net'));
+        $this->assertEquals($expectedData, $attributes, "Flattened string should be returned");
+    }
+
+    public function testFlattenUnchanged()
+    {
+        $config = array(
+            'flatten' => array('eduPersonPrincipalName'),
+            'flattenWith' => '|',
+        );
+        $request = array(
+            'Attributes' => array(
+                'eduPersonPrincipalName' => array('joe@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('eduPersonPrincipalName' => array('joe@example.com'));
+        $this->assertEquals($expectedData, $attributes, "Assertion values should not have changed");
+    }
+
+    /**
+     * Test abort
+     * @expectedException PHPUnit_Framework_Error
+     * @expectedExceptionMessageRegExp /REQUEST_URI/
+     */
+    public function testAbort()
+    {
+        $config = array(
+            'singleValued' => array('eduPersonPrincipalName'),
+        );
+        $request = array(
+            'Attributes' => array(
+                'eduPersonPrincipalName' => array('joe@example.com', 'bob@example.net'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+}
diff --git a/tests/modules/core/lib/Auth/Process/CardinalityTest.php b/tests/modules/core/lib/Auth/Process/CardinalityTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c9df640c60f4aecdbb75d720369bee74f4459fa7
--- /dev/null
+++ b/tests/modules/core/lib/Auth/Process/CardinalityTest.php
@@ -0,0 +1,235 @@
+<?php
+// Alias the PHPUnit 6.0 ancestor if available, else fall back to legacy ancestor
+if (class_exists('\PHPUnit\Framework\TestCase', true) and !class_exists('\PHPUnit_Framework_TestCase', true)) {
+    class_alias('\PHPUnit\Framework\TestCase', '\PHPUnit_Framework_TestCase', true);
+}
+
+/**
+ * Test for the core:Cardinality filter.
+ */
+class Test_Core_Auth_Process_CardinalityTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * 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)
+    {
+        $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1';
+        $_SERVER['REQUEST_METHOD'] = 'GET';
+        $filter = new sspmod_core_Auth_Process_Cardinality($config, null);
+        $filter->process($request);
+        return $request;
+    }
+
+    protected function setUp()
+    {
+        \SimpleSAML_Configuration::loadFromArray(array(), '[ARRAY]', 'simplesaml');
+    }
+
+    /*
+     * Test where a minimum is set but no maximum
+     */
+    public function testMinNoMax()
+    {
+        $config = array(
+            'mail' => array('min' => 1),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('mail' => array('joe@example.com', 'bob@example.com'));
+        $this->assertEquals($expectedData, $attributes, "Assertion values should not have changed");
+    }
+
+    /*
+     * Test where a maximum is set but no minimum
+     */
+    public function testMaxNoMin()
+    {
+        $config = array(
+            'mail' => array('max' => 2),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('mail' => array('joe@example.com', 'bob@example.com'));
+        $this->assertEquals($expectedData, $attributes, "Assertion values should not have changed");
+    }
+
+    /*
+     * Test in bounds within a maximum an minimum
+     */
+    public function testMaxMin()
+    {
+        $config = array(
+            'mail' => array('min' => 1, 'max' => 2),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+        $attributes = $result['Attributes'];
+        $expectedData = array('mail' => array('joe@example.com', 'bob@example.com'));
+        $this->assertEquals($expectedData, $attributes, "Assertion values should not have changed");
+    }
+
+    /**
+     * Test maximum is out of bounds results in redirect
+     * @expectedException PHPUnit_Framework_Error
+     * @expectedExceptionMessageRegExp /REQUEST_URI/
+     */
+    public function testMaxOutOfBounds()
+    {
+        $config = array(
+            'mail' => array('max' => 2),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com', 'fred@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+
+    /**
+     * Test minimum is out of bounds results in redirect
+     * @expectedException PHPUnit_Framework_Error
+     * @expectedExceptionMessageRegExp /REQUEST_URI/
+     */
+    public function testMinOutOfBounds()
+    {
+        $config = array(
+            'mail' => array('min' => 3),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+
+    /**
+     * Test missing attribute results in redirect
+     * @expectedException PHPUnit_Framework_Error
+     * @expectedExceptionMessageRegExp /REQUEST_URI/
+     */
+    public function testMissingAttribute()
+    {
+        $config = array(
+            'mail' => array('min' => 1),
+        );
+        $request = array(
+            'Attributes' => array( ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+
+    /*
+     * Configuration errors
+     */
+
+    /**
+     * Test invalid minimum values
+     * @expectedException SimpleSAML_Error_Exception
+     * @expectedExceptionMessageRegExp /Minimum/
+     */
+    public function testMinInvalid()
+    {
+        $config = array(
+            'mail' => array('min' => false),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+
+    /**
+     * Test invalid minimum values
+     * @expectedException SimpleSAML_Error_Exception
+     * @expectedExceptionMessageRegExp /Minimum/
+     */
+    public function testMinNegative()
+    {
+        $config = array(
+            'mail' => array('min' => -1),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+
+    /**
+     * Test invalid maximum values
+     * @expectedException SimpleSAML_Error_Exception
+     * @expectedExceptionMessageRegExp /Maximum/
+     */
+    public function testMaxInvalid()
+    {
+        $config = array(
+            'mail' => array('max' => false),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+
+    /**
+     * Test maximum < minimum
+     * @expectedException SimpleSAML_Error_Exception
+     * @expectedExceptionMessageRegExp /less than/
+     */
+    public function testMinGreaterThanMax()
+    {
+        $config = array(
+            'mail' => array('min' => 2, 'max' => 1),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+
+    /**
+     * Test invalid attribute name
+     * @expectedException SimpleSAML_Error_Exception
+     * @expectedExceptionMessageRegExp /Invalid attribute/
+     */
+    public function testInvalidAttributeName()
+    {
+        $config = array(
+            array('min' => 2, 'max' => 1),
+        );
+        $request = array(
+            'Attributes' => array(
+                'mail' => array('joe@example.com', 'bob@example.com'),
+            ),
+        );
+        $result = self::processFilter($config, $request);
+    }
+}