diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst
index dce8feab367d241aa4991f2c87769fcd6bf42562..e30e03e3741dccae10a75c7055577b6db5c01893 100644
--- a/docs/source/configuration.rst
+++ b/docs/source/configuration.rst
@@ -52,6 +52,20 @@ The client can also be configured with the number of passes to expect in exchang
 
 The value given here must agree with the value the issuer uses in its configuration or redemption may fail.
 
+allowed-public-keys
+~~~~~~~~~~~~~~~~~~~
+
+Regardless of which redeemer is selected,
+the client must also be configured with the public part of the issuer key pair which it will allow to sign tokens::
+
+  [storageclient.plugins.privatestorageio-zkapauthz-v1]
+  allowed-public-keys = AAAA...,BBBB...,CCCC...
+
+The ``allowed-public-keys`` value is a comma-separated list of encoded public keys.
+When tokens are received from an issuer during redemption,
+these are the only public keys which will satisfy the redeemer and cause the tokens to be made available to the client to be spent.
+Tokens received with any other public key will be sequestered and will *not* be spent until some further action is taken.
+
 Server
 ------
 
diff --git a/docs/source/interface.rst b/docs/source/interface.rst
index 5911d12a30ecbf19324d0cdeee9696a77c27fd7e..d3223d6fe60e9a9af9a009050a8a2b181816fe9d 100644
--- a/docs/source/interface.rst
+++ b/docs/source/interface.rst
@@ -147,17 +147,23 @@ This endpoint accepts several query arguments:
 
   * limit: An integer limiting the number of unblinded tokens to retrieve.
   * position: A string which can be compared against unblinded token values.
-    Only unblinded tokens which sort as great than this value are returned.
+    Only unblinded tokens which follow this token in the stable order are returned.
 
 This endpoint accepts no request body.
 
 The response is **OK** with ``application/json`` content-type response body like::
 
   { "total": <integer>
+  , "spendable": <integer>
   , "unblinded-tokens": [<unblinded token string>, ...]
   , "lease-maintenance-spending": <spending object>
   }
 
+The value associated with ``total`` gives the total number of unblinded tokens in the node's database
+(independent of any limit placed on this query).
+The value associated with ``spendable`` gives the number of unblinded tokens in the node's database which can actually be spent.
+The value associated with ``unblinded-tokens`` gives the requested list of unblinded tokens.
+
 The ``<spending object>`` may be ``null`` if the lease maintenance process has never run.
 If it has run,
 ``<spending object>`` has two properties:
diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index f7a5ee31dbc41bc93ebb4ab383c873c1c79a3665..44a4b0211864796234874aca316066330635132b 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -705,6 +705,9 @@ class PaymentController(object):
         redeeming a voucher, if no other count is given when the redemption is
         started.
 
+    :ivar set[unicode] allowed_public_keys: The base64-encoded public keys for
+        which to accept tokens.
+
     :ivar dict[unicode, Redeeming] _active: A mapping from voucher identifiers
         which currently have redemption attempts in progress to a
         ``Redeeming`` state representing the attempt.
@@ -735,6 +738,8 @@ class PaymentController(object):
     redeemer = attr.ib()
     default_token_count = attr.ib()
 
+    allowed_public_keys = attr.ib(validator=attr.validators.instance_of(set))
+
     num_redemption_groups = attr.ib(default=16)
 
     _clock = attr.ib(default=None)
@@ -945,6 +950,7 @@ class PaymentController(object):
             result.public_key,
             result.unblinded_tokens,
             completed=(counter + 1 == self.num_redemption_groups),
+            spendable=result.public_key in self.allowed_public_keys,
         )
         return True
 
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 7c62b8ff8e014b68c598822fb4a3b524fa83d38e..391ea1e4c40a75e28e3be5bc33d01a8a32a5bfc5 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -393,33 +393,36 @@ class VoucherStore(object):
             in refs
         )
 
-    def _insert_unblinded_tokens(self, cursor, unblinded_tokens):
+    def _insert_unblinded_tokens(self, cursor, unblinded_tokens, group_id):
         """
         Helper function to really insert unblinded tokens into the database.
         """
         cursor.executemany(
             """
-            INSERT INTO [unblinded-tokens] VALUES (?)
+            INSERT INTO [unblinded-tokens] ([token], [redemption-group]) VALUES (?, ?)
             """,
             list(
-                (token,)
+                (token, group_id)
                 for token
                 in unblinded_tokens
             ),
         )
 
     @with_cursor
-    def insert_unblinded_tokens(self, cursor, unblinded_tokens):
+    def insert_unblinded_tokens(self, cursor, unblinded_tokens, group_id):
         """
         Store some unblinded tokens, for example as part of a backup-restore
         process.
 
         :param list[unicode] unblinded_tokens: The unblinded tokens to store.
+
+        :param int group_id: The unique identifier of the redemption group to
+            which these tokens belong.
         """
-        self._insert_unblinded_tokens(cursor, unblinded_tokens)
+        self._insert_unblinded_tokens(cursor, unblinded_tokens, group_id)
 
     @with_cursor
-    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed):
+    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed, spendable):
         """
         Store some unblinded tokens received from redemption of a voucher.
 
@@ -435,32 +438,58 @@ class VoucherStore(object):
 
         :param bool completed: ``True`` if redemption of this voucher is now
             complete, ``False`` otherwise.
+
+        :param bool spendable: ``True`` if it should be possible to spend the
+            inserted tokens, ``False`` otherwise.
         """
         if  completed:
             voucher_state = u"redeemed"
         else:
             voucher_state = u"pending"
 
+        if spendable:
+            token_count_increase = len(unblinded_tokens)
+            sequestered_count_increase = 0
+        else:
+            token_count_increase = 0
+            sequestered_count_increase = len(unblinded_tokens)
+
+        cursor.execute(
+            """
+            INSERT INTO [redemption-groups] ([voucher], [public-key], [spendable]) VALUES (?, ?, ?)
+            """,
+            (voucher, public_key, spendable),
+        )
+        group_id = cursor.lastrowid
+
+        self._log.info(
+            "Recording {count} {unspendable}spendable unblinded tokens from public key {public_key}.",
+            count=len(unblinded_tokens),
+            unspendable="" if spendable else "un",
+            public_key=public_key,
+        )
+
         cursor.execute(
             """
             UPDATE [vouchers]
             SET [state] = ?
               , [token-count] = COALESCE([token-count], 0) + ?
+              , [sequestered-count] = COALESCE([sequestered-count], 0) + ?
               , [finished] = ?
-              , [public-key] = ?
               , [counter] = [counter] + 1
             WHERE [number] = ?
             """,
             (
                 voucher_state,
-                len(unblinded_tokens),
+                token_count_increase,
+                sequestered_count_increase,
                 self.now(),
-                public_key,
                 voucher,
             ),
         )
         if cursor.rowcount == 0:
             raise ValueError("Cannot insert tokens for unknown voucher; add voucher first")
+
         self._insert_unblinded_tokens(
             cursor,
             list(
@@ -468,6 +497,7 @@ class VoucherStore(object):
                 for t
                 in unblinded_tokens
             ),
+            group_id,
         )
 
     @with_cursor
@@ -522,6 +552,10 @@ class VoucherStore(object):
         which have not had their state changed to invalid or spent have been
         reset.
 
+        :raise NotEnoughTokens: If there are fewer than the requested number
+            of tokens available to be spent.  In this case, all tokens remain
+            available to future calls and do not need to be reset.
+
         :return list[UnblindedTokens]: The removed unblinded tokens.
         """
         if count > _SQLITE3_INTEGER_MAX:
@@ -531,9 +565,11 @@ class VoucherStore(object):
 
         cursor.execute(
             """
-            SELECT [token]
-            FROM [unblinded-tokens]
-            WHERE [token] NOT IN [in-use]
+            SELECT T.[token]
+            FROM   [unblinded-tokens] AS T, [redemption-groups] AS G
+            WHERE  T.[redemption-group] = G.[rowid]
+            AND    G.[spendable] = 1
+            AND    T.[token] NOT IN [in-use]
             LIMIT ?
             """,
             (count,),
@@ -554,6 +590,25 @@ class VoucherStore(object):
             in texts
         )
 
+    @with_cursor
+    def count_unblinded_tokens(self, cursor):
+        """
+        Return the largest number of unblinded tokens that can be requested from
+        ``get_unblinded_tokens`` without causing it to raise
+        ``NotEnoughTokens``.
+        """
+        cursor.execute(
+            """
+            SELECT count(1)
+            FROM   [unblinded-tokens] AS T, [redemption-groups] AS G
+            WHERE  T.[redemption-group] = G.[rowid]
+            AND    G.[spendable] = 1
+            AND    T.[token] NOT IN [in-use]
+            """,
+        )
+        (count,) = cursor.fetchone()
+        return count
+
     @with_cursor
     def discard_unblinded_tokens(self, cursor, unblinded_tokens):
         """
@@ -658,7 +713,7 @@ class VoucherStore(object):
         """
         cursor.execute(
             """
-            SELECT [token] FROM [unblinded-tokens]
+            SELECT [token] FROM [unblinded-tokens] ORDER BY [rowid]
             """,
         )
         tokens = cursor.fetchall()
@@ -928,13 +983,9 @@ class Redeemed(object):
     :ivar datetime finished: The time when the redemption finished.
 
     :ivar int token_count: The number of tokens the voucher was redeemed for.
-
-    :ivar unicode public_key: The public part of the key used to sign the
-        tokens for this voucher.
     """
     finished = attr.ib(validator=attr.validators.instance_of(datetime))
     token_count = attr.ib(validator=attr.validators.instance_of((int, long)))
-    public_key = attr.ib(validator=attr.validators.optional(attr.validators.instance_of(unicode)))
 
     def should_start_redemption(self):
         return False
@@ -944,7 +995,6 @@ class Redeemed(object):
             u"name": u"redeemed",
             u"finished": self.finished.isoformat(),
             u"token-count": self.token_count,
-            u"public-key": self.public_key,
         }
 
 
@@ -1067,7 +1117,6 @@ class Voucher(object):
                 return Redeemed(
                     parse_datetime(row[0], delimiter=u" "),
                     row[1],
-                    row[2],
                 )
             raise ValueError("Unknown voucher state {}".format(state))
 
@@ -1111,7 +1160,6 @@ class Voucher(object):
             state = Redeemed(
                 finished=parse_datetime(state_json[u"finished"]),
                 token_count=state_json[u"token-count"],
-                public_key=state_json[u"public-key"],
             )
         elif state_name == u"unpaid":
             state = Unpaid(
diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index e5e31eae14212a1b9d9f9ad716e96ae27460d786..1663b0c291d030e16614f894e54104f0a4dc73fe 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -64,6 +64,7 @@ from .storage_common import (
     get_configured_shares_total,
     get_configured_pass_value,
     get_configured_lease_duration,
+    get_configured_allowed_public_keys,
 )
 
 from .pricecalculator import (
@@ -157,6 +158,7 @@ def from_configuration(
         store,
         redeemer,
         default_token_count,
+        allowed_public_keys=get_configured_allowed_public_keys(node_config),
         clock=clock,
     )
 
@@ -368,6 +370,7 @@ class _UnblindedTokenCollection(Resource):
 
         return dumps({
             u"total": len(unblinded_tokens),
+            u"spendable": self._store.count_unblinded_tokens(),
             u"unblinded-tokens": list(islice((
                 token
                 for token
@@ -383,7 +386,7 @@ class _UnblindedTokenCollection(Resource):
         """
         application_json(request)
         unblinded_tokens = load(request.content)[u"unblinded-tokens"]
-        self._store.insert_unblinded_tokens(unblinded_tokens)
+        self._store.insert_unblinded_tokens(unblinded_tokens, group_id=0)
         return dumps({})
 
 
diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py
index 5044153e08c31a211d8bcaf35dbb2efffea46626..27890a32fcebc15cfebae08580e7daf5d33f69cc 100644
--- a/src/_zkapauthorizer/schema.py
+++ b/src/_zkapauthorizer/schema.py
@@ -128,6 +128,8 @@ _UPGRADES = {
 
     1: [
         """
+        -- Incorrectly track a single public-key for all.  Later version of
+        -- the schema moves this elsewhere.
         ALTER TABLE [vouchers] ADD COLUMN [public-key] text
         """,
     ],
@@ -167,4 +169,57 @@ _UPGRADES = {
         )
         """,
     ],
+
+    5: [
+        """
+        -- Create a table where rows represent a single group of unblinded
+        -- tokens all redeemed together.  Some number of these rows represent
+        -- a complete redemption of a voucher.
+        CREATE TABLE [redemption-groups] (
+            -- A unique identifier for this redemption group.
+            [rowid] INTEGER PRIMARY KEY,
+
+            -- The text representation of the voucher this group is associated with.
+            [voucher] text,
+
+            -- A flag indicating whether these tokens can be spent or if
+            -- they're being held for further inspection.
+            [spendable] integer,
+
+            -- The public key seen when redeeming this group.
+            [public-key] text
+        )
+        """,
+
+        """
+        -- Create one redemption group for every existing, redeemed voucher.
+        -- These tokens were probably *not* all redeemed in one group but
+        -- we've only preserved one public key for them so we can't do much
+        -- better than this.
+        INSERT INTO [redemption-groups] ([voucher], [public-key], [spendable])
+            SELECT DISTINCT([number]), [public-key], 1 FROM [vouchers] WHERE [state] = "redeemed"
+        """,
+
+        """
+        -- Give each voucher a count of "sequestered" tokens.  Currently,
+        -- these are unspendable tokens that were issued using a disallowed
+        -- public key.
+        ALTER TABLE [vouchers] ADD COLUMN [sequestered-count] integer NOT NULL DEFAULT 0
+        """,
+
+        """
+        -- Give each unblinded token a reference to the [redemption-groups]
+        -- table identifying the group that token arrived with.  This lets us
+        -- act collectively on tokens from these groups and identify tokens
+        -- which are spendable.
+        --
+        -- The default value is provided for rows that
+        -- existed prior to this upgrade which had no group association.  For
+        -- unblinded tokens to exist at all there must be at least one voucher
+        -- in the vouchers table.  [redemption-groups] will therefore have at
+        -- least one row added to it (by the statement a few lines above).
+        -- Note that SQLite3 rowid numbering begins at 1.
+        ALTER TABLE [unblinded-tokens] ADD COLUMN [redemption-group] integer DEFAULT 1
+        """,
+    ],
 }
diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py
index 829d5a17e3facf46a12fa2685e788d012e086622..0709e4152cd6773d0c8a39b9055df3202f15bab2 100644
--- a/src/_zkapauthorizer/storage_common.py
+++ b/src/_zkapauthorizer/storage_common.py
@@ -131,6 +131,17 @@ def get_configured_lease_duration(node_config):
     return 31 * 24 * 60 * 60
 
 
+def get_configured_allowed_public_keys(node_config):
+    """
+    Read the set of allowed issuer public keys from the given configuration.
+    """
+    section_name = u"storageclient.plugins.privatestorageio-zkapauthz-v1"
+    return set(node_config.get_config(
+        section=section_name,
+        option=u"allowed-public-keys",
+    ).strip().split(","))
+
+
 def required_passes(bytes_per_pass, share_sizes):
     """
     Calculate the number of passes that are required to store shares of the
diff --git a/src/_zkapauthorizer/tests/fixtures.py b/src/_zkapauthorizer/tests/fixtures.py
index 392520c90b82778aeb5879a972c92517dee52ba2..7f2246a2e15a605895730dd0fff3dfe3ea3978f1 100644
--- a/src/_zkapauthorizer/tests/fixtures.py
+++ b/src/_zkapauthorizer/tests/fixtures.py
@@ -154,6 +154,7 @@ class ConfiglessMemoryVoucherStore(Fixture):
             # minimum token count requirement (can't have fewer tokens
             # than groups).
             num_redemption_groups=1,
+            allowed_public_keys={self._public_key},
             clock=Clock(),
         ).redeem(
             voucher,
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index d20eb91613d294470a76f8b71933615d7d732162..4fe4df3f2f0b58fc860651a7ff9c4ddc011f0688 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -230,27 +230,71 @@ def server_configurations(signing_key_path):
     )
 
 
+def dummy_ristretto_keys_sets():
+    """
+    Build small sets of "dummy" Ristretto keys.  See ``dummy_ristretto_keys``.
+    """
+    return sets(dummy_ristretto_keys(), min_size=1, max_size=5)
+
+
+def zkapauthz_configuration(
+    extra_configurations,
+    allowed_public_keys=dummy_ristretto_keys_sets(),
+):
+    """
+    Build ZKAPAuthorizer client plugin configuration dictionaries.
+
+    :param extra_configurations: A strategy to build any of the optional /
+        alternative sections of the configuration.
+
+    :param allowed_public_keys: A strategy to build sets of allowed public
+        keys for the configuration.
+
+    :return: A strategy that builds the basic, required part of the client
+        plugin configuration plus an extra values built by
+        ``extra_configurations``.
+    """
+
+    def merge(extra_configuration, allowed_public_keys):
+        config = {
+            u"default-token-count": u"32",
+            u"allowed-public-keys": u",".join(allowed_public_keys),
+        }
+        config.update(extra_configuration)
+        return config
+
+    return builds(
+        merge,
+        extra_configurations,
+        allowed_public_keys,
+    )
+
+
 def client_ristrettoredeemer_configurations():
     """
     Build Ristretto-using configuration values for the client-side plugin.
     """
-    return just({
+    return zkapauthz_configuration(just({
         u"ristretto-issuer-root-url": u"https://issuer.example.invalid/",
         u"redeemer": u"ristretto",
-        u"default-token-count": u"32",
-    })
+    }))
 
 
 def client_dummyredeemer_configurations():
     """
     Build DummyRedeemer-using configuration values for the client-side plugin.
     """
-    return dummy_ristretto_keys().map(
-        lambda key: {
-            u"redeemer": u"dummy",
-            u"issuer-public-key": key,
-            u"default-token-count": u"32",
-        })
+    def share_a_key(allowed_keys):
+        return zkapauthz_configuration(
+            just({
+                u"redeemer": u"dummy",
+                # Pick out one of the allowed public keys so that the dummy
+                # appears to produce usable tokens.
+                u"issuer-public-key": next(iter(allowed_keys)),
+            }),
+            allowed_public_keys=just(allowed_keys),
+        )
+    return dummy_ristretto_keys_sets().flatmap(share_a_key)
 
 
 def token_counts():
@@ -265,41 +309,37 @@ def client_doublespendredeemer_configurations(default_token_counts=token_counts(
     """
     Build DoubleSpendRedeemer-using configuration values for the client-side plugin.
     """
-    return fixed_dictionaries({
-        u"redeemer": just(u"double-spend"),
-        u"default-token-count": default_token_counts.map(str),
-    })
+    return zkapauthz_configuration(just({
+        u"redeemer": u"double-spend",
+    }))
 
 
 def client_unpaidredeemer_configurations():
     """
     Build UnpaidRedeemer-using configuration values for the client-side plugin.
     """
-    return just({
+    return zkapauthz_configuration(just({
         u"redeemer": u"unpaid",
-        u"default-token-count": u"32",
-    })
+    }))
 
 
 def client_nonredeemer_configurations():
     """
     Build NonRedeemer-using configuration values for the client-side plugin.
     """
-    return just({
+    return zkapauthz_configuration(just({
         u"redeemer": u"non",
-        u"default-token-count": u"32",
-    })
+    }))
 
 
 def client_errorredeemer_configurations(details):
     """
     Build ErrorRedeemer-using configuration values for the client-side plugin.
     """
-    return just({
+    return zkapauthz_configuration(just({
         u"redeemer": u"error",
         u"details": details,
-        u"default-token-count": u"32",
-    })
+    }))
 
 
 def direct_tahoe_configs(
@@ -394,7 +434,6 @@ def redeemed_states():
         Redeemed,
         finished=datetimes(),
         token_count=one_of(integers(min_value=1)),
-        public_key=dummy_ristretto_keys(),
     )
 
 
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index 79c1cdcd2eec1c0a2905183be9a67631727582f8..e420b676f2171f804c989b9e183bce831250dd97 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -164,6 +164,7 @@ from ..storage_common import (
     required_passes,
     get_configured_pass_value,
     get_configured_lease_duration,
+    get_configured_allowed_public_keys,
 )
 
 from .strategies import (
@@ -192,32 +193,6 @@ from .json import (
 
 TRANSIENT_ERROR = u"something went wrong, who knows what"
 
-def get_dummyredeemer_public_key(plugin_name, node_config):
-    """
-    Get the issuer public key a ``DummyRedeemer`` has been configured with.
-
-    :param unicode plugin_name: The plugin name to use to choose a
-        configuration section.
-
-    :param _Config node_config: See ``from_configuration``.
-    """
-    section_name = u"storageclient.plugins.{}".format(plugin_name)
-    redeemer_kind = node_config.get_config(
-        section=section_name,
-        option=u"redeemer",
-    )
-    if redeemer_kind != "dummy":
-        raise ValueError(
-            "Cannot read dummy redeemer public key from configuration for {!r} redeemer.".format(
-                redeemer_kind,
-            ),
-        )
-    return node_config.get_config(
-        section=section_name,
-        option=u"issuer-public-key",
-    ).decode("utf-8")
-
-
 # Helper to work-around https://github.com/twisted/treq/issues/161
 def uncooperator(started=True):
     return Cooperator(
@@ -390,6 +365,30 @@ def add_api_token_to_config(basedir, config, api_auth_token):
     config.write_private_config(b"api_auth_token", api_auth_token)
 
 
+class FromConfigurationTests(TestCase):
+    """
+    Tests for ``from_configuration``.
+    """
+    @given(tahoe_configs())
+    def test_allowed_public_keys(self, get_config):
+        """
+        The controller created by ``from_configuration`` is configured to allow
+        the public keys found in the configuration.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        allowed_public_keys = get_configured_allowed_public_keys(config)
+
+        # root_from_config is just an easier way to call from_configuration
+        root = root_from_config(config, datetime.now)
+        self.assertThat(
+            root.controller,
+            MatchesStructure(
+                allowed_public_keys=Equals(allowed_public_keys),
+            ),
+        )
+
+
 class GetTokenCountTests(TestCase):
     """
     Tests for ``get_token_count``.
@@ -737,6 +736,7 @@ class UnblindedTokenTests(TestCase):
             requesting,
             succeeded_with_unblinded_tokens_with_matcher(
                 num_tokens,
+                Equals(num_tokens),
                 AllMatch(
                     MatchesAll(
                         GreaterThan(position),
@@ -751,9 +751,10 @@ class UnblindedTokenTests(TestCase):
         tahoe_configs(),
         api_auth_tokens(),
         vouchers(),
-        integers(min_value=0, max_value=100),
+        integers(min_value=1, max_value=16),
+        integers(min_value=1, max_value=128),
     )
-    def test_get_order_matches_use_order(self, get_config, api_auth_token, voucher, extra_tokens):
+    def test_get_order_matches_use_order(self, get_config, api_auth_token, voucher, num_redemption_groups, extra_tokens):
         """
         The first unblinded token returned in a response to a **GET** request is
         the first token to be used to authorize a storage request.
@@ -791,6 +792,7 @@ class UnblindedTokenTests(TestCase):
         )
         root = root_from_config(config, datetime.now)
 
+        root.controller.num_redemption_groups = num_redemption_groups
         num_tokens = root.controller.num_redemption_groups + extra_tokens
 
         # Put in a number of tokens with which to test.
@@ -869,6 +871,7 @@ class UnblindedTokenTests(TestCase):
 
 def succeeded_with_unblinded_tokens_with_matcher(
         all_token_count,
+        match_spendable_token_count,
         match_unblinded_tokens,
         match_lease_maint_spending,
 ):
@@ -893,6 +896,7 @@ def succeeded_with_unblinded_tokens_with_matcher(
                 succeeded(
                     ContainsDict({
                         u"total": Equals(all_token_count),
+                        u"spendable": match_spendable_token_count,
                         u"unblinded-tokens": match_unblinded_tokens,
                         u"lease-maintenance-spending": match_lease_maint_spending,
                     }),
@@ -914,11 +918,12 @@ def succeeded_with_unblinded_tokens(all_token_count, returned_token_count):
     """
     return succeeded_with_unblinded_tokens_with_matcher(
         all_token_count,
-        MatchesAll(
+        match_spendable_token_count=Equals(all_token_count),
+        match_unblinded_tokens=MatchesAll(
             HasLength(returned_token_count),
             AllMatch(IsInstance(unicode)),
         ),
-        matches_lease_maintenance_spending(),
+        match_lease_maint_spending=matches_lease_maintenance_spending(),
     )
 
 def matches_lease_maintenance_spending():
@@ -1134,7 +1139,6 @@ class VoucherTests(TestCase):
         are included in a json-encoded response body.
         """
         count = get_token_count("privatestorageio-zkapauthz-v1", config)
-        public_key = get_dummyredeemer_public_key("privatestorageio-zkapauthz-v1", config)
         return self._test_get_known_voucher(
             config,
             api_auth_token,
@@ -1147,7 +1151,6 @@ class VoucherTests(TestCase):
                 state=Equals(Redeemed(
                     finished=now,
                     token_count=count,
-                    public_key=public_key,
                 )),
             ),
         )
@@ -1313,7 +1316,6 @@ class VoucherTests(TestCase):
         vouchers.
         """
         count = get_token_count("privatestorageio-zkapauthz-v1", config)
-        public_key = get_dummyredeemer_public_key("privatestorageio-zkapauthz-v1", config)
         return self._test_list_vouchers(
             config,
             api_auth_token,
@@ -1328,7 +1330,6 @@ class VoucherTests(TestCase):
                         state=Redeemed(
                             finished=now,
                             token_count=count,
-                            public_key=public_key,
                         ),
                     ).marshal()
                     for voucher
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 73398aabd9fcbdaecdb4e8b4ed4c0dcbb27b2ff8..cf7726f913566ca06412d523b9c80c7126987017 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -66,6 +66,7 @@ from hypothesis.strategies import (
     datetimes,
     lists,
     sampled_from,
+    randoms,
 )
 from twisted.python.url import (
     URL,
@@ -146,6 +147,7 @@ from .matchers import (
 )
 from .fixtures import (
     TemporaryVoucherStore,
+    ConfiglessMemoryVoucherStore,
 )
 
 
@@ -231,6 +233,7 @@ class PaymentControllerTests(TestCase):
             store,
             DummyRedeemer(public_key),
             default_token_count=100,
+            allowed_public_keys={public_key},
             clock=Clock(),
         )
 
@@ -267,6 +270,7 @@ class PaymentControllerTests(TestCase):
             store,
             NonRedeemer(),
             default_token_count=100,
+            allowed_public_keys=set(),
             clock=Clock(),
         )
         self.assertThat(
@@ -304,6 +308,7 @@ class PaymentControllerTests(TestCase):
             # Require more success than we're going to get so it doesn't
             # finish.
             num_redemption_groups=counter,
+            allowed_public_keys={public_key},
             clock=Clock(),
         )
 
@@ -360,6 +365,7 @@ class PaymentControllerTests(TestCase):
                 ),
                 default_token_count=num_tokens,
                 num_redemption_groups=num_redemption_groups,
+                allowed_public_keys={public_key},
                 clock=Clock(),
             )
             self.assertThat(
@@ -386,6 +392,7 @@ class PaymentControllerTests(TestCase):
                 # The number of redemption groups must not change for
                 # redemption of a particular voucher.
                 num_redemption_groups=num_redemption_groups,
+                allowed_public_keys={public_key},
                 clock=Clock(),
             )
 
@@ -399,7 +406,6 @@ class PaymentControllerTests(TestCase):
                 model_Redeemed(
                     finished=now,
                     token_count=num_tokens,
-                    public_key=public_key,
                 ),
             ),
         )
@@ -421,6 +427,7 @@ class PaymentControllerTests(TestCase):
             redeemer,
             default_token_count=num_tokens,
             num_redemption_groups=num_redemption_groups,
+            allowed_public_keys=set(),
             clock=Clock(),
         )
         self.assertThat(
@@ -445,6 +452,7 @@ class PaymentControllerTests(TestCase):
             store,
             DummyRedeemer(public_key),
             default_token_count=100,
+            allowed_public_keys={public_key},
             clock=Clock(),
         )
         self.assertThat(
@@ -458,7 +466,6 @@ class PaymentControllerTests(TestCase):
             Equals(model_Redeemed(
                 finished=now,
                 token_count=100,
-                public_key=public_key,
             )),
         )
 
@@ -473,6 +480,7 @@ class PaymentControllerTests(TestCase):
             store,
             DoubleSpendRedeemer(),
             default_token_count=100,
+            allowed_public_keys=set(),
             clock=Clock(),
         )
         self.assertThat(
@@ -503,6 +511,7 @@ class PaymentControllerTests(TestCase):
             store,
             UnpaidRedeemer(),
             default_token_count=100,
+            allowed_public_keys=set(),
             clock=Clock(),
         )
         self.assertThat(
@@ -523,6 +532,7 @@ class PaymentControllerTests(TestCase):
             store,
             DummyRedeemer(public_key),
             default_token_count=100,
+            allowed_public_keys={public_key},
             clock=Clock(),
         )
 
@@ -553,6 +563,7 @@ class PaymentControllerTests(TestCase):
             store,
             UnpaidRedeemer(),
             default_token_count=100,
+            allowed_public_keys=set(),
             clock=clock,
         )
         self.assertThat(
@@ -586,6 +597,152 @@ class PaymentControllerTests(TestCase):
             ),
         )
 
+    @given(
+        # Get a random object so we can shuffle allowed and disallowed keys
+        # together in an unpredictable but Hypothesis-deterministicway.
+        randoms(),
+        # Control time just to control it.  Nothing particularly interesting
+        # relating to time happens in this test.
+        clocks(),
+        # Build a voucher number to use with the attempted redemption.
+        vouchers(),
+        # Build a number of redemption groups.
+        integers(min_value=1, max_value=16).flatmap(
+            # Build a number of groups to have an allowed key
+            lambda num_groups: integers(min_value=0, max_value=num_groups).flatmap(
+                # Build distinct public keys
+                lambda num_allowed_key_groups: lists(
+                    dummy_ristretto_keys(),
+                    min_size=num_groups,
+                    max_size=num_groups,
+                    unique=True,
+                ).map(
+                    # Split the keys into allowed and disallowed groups
+                    lambda public_keys: (public_keys[:num_allowed_key_groups], public_keys[num_allowed_key_groups:]),
+                ),
+            ),
+        ),
+        # Build a number of extra tokens to request beyond the minimum number
+        # required by the number of redemption groups we have.
+        integers(min_value=0, max_value=32),
+    )
+    def test_sequester_tokens_for_untrusted_key(self, random, clock, voucher, public_keys, extra_token_count):
+        """
+        All unblinded tokens which are returned from the redemption process
+        associated with a public key that the controller has not been
+        configured to trust are not made available to be spent.  The
+        corresponding voucher still reaches the redeemed state but with the
+        number of sequestered tokens subtracted from its ``token_count``.
+        """
+        # The controller will be configured to allow one group of keys but not
+        # the other.
+        allowed_public_keys, disallowed_public_keys = public_keys
+        all_public_keys = allowed_public_keys + disallowed_public_keys
+
+        # Compute the total number of tokens we'll request, spread across all
+        # redemption groups.
+        token_count = len(all_public_keys) + extra_token_count
+
+        # Mix them up so they're not always presented to the controller in the
+        # same order - and in particular so they're not always presented such
+        # that all allowed keys come before all disallowed keys.
+        random.shuffle(all_public_keys)
+
+        # Redeem the voucher in enough groups so that each key can be
+        # presented once.
+        num_redemption_groups = len(all_public_keys)
+
+        datetime_now = lambda: datetime.utcfromtimestamp(clock.seconds())
+        store = self.useFixture(ConfiglessMemoryVoucherStore(datetime_now)).store
+
+        redeemers = list(
+            DummyRedeemer(public_key)
+            for public_key
+            in all_public_keys
+        )
+
+        controller = PaymentController(
+            store,
+            IndexedRedeemer(redeemers),
+            default_token_count=token_count,
+            num_redemption_groups=num_redemption_groups,
+            allowed_public_keys=set(allowed_public_keys),
+            clock=clock,
+        )
+
+        # Even with disallowed public keys, the *redemption* is considered
+        # successful.
+        self.assertThat(
+            controller.redeem(voucher),
+            succeeded(Always()),
+        )
+
+        def count_in_group(public_keys, key_group):
+            return sum((
+                token_count_for_group(num_redemption_groups, token_count, n)
+                for n, public_key
+                in enumerate(public_keys)
+                if public_key in key_group
+            ), 0)
+
+        allowed_token_count = count_in_group(all_public_keys, allowed_public_keys)
+        disallowed_token_count = count_in_group(all_public_keys, disallowed_public_keys)
+
+        # As a sanity check: allowed + disallowed should equal total or we've
+        # screwed up the test logic.
+        self.assertThat(
+            allowed_token_count + disallowed_token_count,
+            Equals(token_count),
+        )
+
+        # The counts on the voucher object should reflect what was allowed and
+        # what was disallowed.
+        self.expectThat(
+            store.get(voucher),
+            MatchesStructure(
+                expected_tokens=Equals(token_count),
+                state=Equals(
+                    model_Redeemed(
+                        finished=datetime_now(),
+                        token_count=allowed_token_count,
+                    ),
+                ),
+            ),
+        )
+
+        # Also the actual number of tokens available should agree.
+        self.expectThat(
+            store.count_unblinded_tokens(),
+            Equals(allowed_token_count),
+        )
+
+        # And finally only tokens from the groups using an allowed key should
+        # be made available to be spent.
+        voucher_obj = store.get(voucher)
+        allowed_tokens = list(
+            unblinded_token
+            for counter, redeemer in enumerate(redeemers)
+            if redeemer._public_key in allowed_public_keys
+            for unblinded_token
+            in redeemer.redeemWithCounter(
+                voucher_obj,
+                counter,
+                redeemer.random_tokens_for_voucher(
+                    voucher_obj,
+                    counter,
+                    token_count_for_group(
+                        num_redemption_groups,
+                        token_count,
+                        counter,
+                    ),
+                ),
+            ).result.unblinded_tokens
+        )
+        self.expectThat(
+            store.get_unblinded_tokens(store.count_unblinded_tokens()),
+            Equals(allowed_tokens),
+        )
+
 
 NOWHERE = URL.from_text(u"https://127.0.0.1/")
 
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 6a0d4450f68f00818af4b10ddfaf3634f430a865..5af002721ebcc7ce1b7ae45c8b2d0f2e0b045558 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -392,6 +392,7 @@ class VoucherStoreTests(TestCase):
             public_key,
             unblinded_tokens,
             completed=data.draw(booleans()),
+            spendable=True,
         )
 
         backed_up_tokens = store.backup()[u"unblinded-tokens"]
@@ -725,6 +726,7 @@ class UnblindedTokenStoreTests(TestCase):
                 public_key,
                 unblinded_tokens,
                 completed,
+                spendable=True,
             ),
             raises(ValueError),
         )
@@ -739,14 +741,26 @@ class UnblindedTokenStoreTests(TestCase):
     )
     def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, completed, data):
         """
-        Unblinded tokens that are added to the store can later be retrieved.
+        Unblinded tokens that are added to the store can later be retrieved and counted.
         """
         random_tokens, unblinded_tokens = paired_tokens(data)
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, len(random_tokens), 0, lambda: random_tokens)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed, spendable=True)
+
+        # All the tokens just inserted should be counted.
+        self.expectThat(
+            store.count_unblinded_tokens(),
+            Equals(len(unblinded_tokens)),
+        )
         retrieved_tokens = store.get_unblinded_tokens(len(random_tokens))
 
+        # All the tokens just extracted should not be counted.
+        self.expectThat(
+            store.count_unblinded_tokens(),
+            Equals(0),
+        )
+
         self.expectThat(
             set(unblinded_tokens),
             Equals(set(retrieved_tokens)),
@@ -784,7 +798,7 @@ class UnblindedTokenStoreTests(TestCase):
 
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, len(random), 0, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True, spendable=True)
         loaded_voucher = store.get(voucher_value)
         self.assertThat(
             loaded_voucher,
@@ -793,7 +807,6 @@ class UnblindedTokenStoreTests(TestCase):
                 state=Equals(Redeemed(
                     finished=now,
                     token_count=num_tokens,
-                    public_key=public_key,
                 )),
             ),
         )
@@ -852,7 +865,7 @@ class UnblindedTokenStoreTests(TestCase):
         )
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, len(random), 0, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True, spendable=True)
         self.assertThat(
             lambda: store.mark_voucher_double_spent(voucher_value),
             raises(ValueError),
@@ -896,6 +909,7 @@ class UnblindedTokenStoreTests(TestCase):
             public_key,
             unblinded,
             completed,
+            spendable=True,
         )
         self.assertThat(
             lambda: store.get_unblinded_tokens(num_tokens + extra),
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index 5de8f4121bcbca70f1c1719080fbfdd70dc90093..ee8f0016c0a8a197541f35dae6df6f837c1ceeaf 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -510,6 +510,7 @@ class ClientPluginTests(TestCase):
             DummyRedeemer(public_key),
             default_token_count=num_passes,
             num_redemption_groups=1,
+            allowed_public_keys={public_key},
             clock=Clock(),
         )
         # Get a token inserted into the store.
diff --git a/src/_zkapauthorizer/tests/test_storage_client.py b/src/_zkapauthorizer/tests/test_storage_client.py
index 38a0e80bce19b2f041eaa45ba7425de393391674..c697090885784e259258b873c0ce68e340e190f2 100644
--- a/src/_zkapauthorizer/tests/test_storage_client.py
+++ b/src/_zkapauthorizer/tests/test_storage_client.py
@@ -48,6 +48,8 @@ from hypothesis import (
 )
 from hypothesis.strategies import (
     sampled_from,
+    integers,
+    sets,
 )
 
 from twisted.internet.defer import (
@@ -55,15 +57,10 @@ from twisted.internet.defer import (
     fail,
 )
 
-from .matchers import (
-    even,
-    odd,
-    raises,
+from allmydata.client import (
+    config_from_string,
 )
 
-from .strategies import (
-    pass_counts,
-)
 
 from ..api import (
     MorePassesRequired,
@@ -71,19 +68,136 @@ from ..api import (
 from ..model import (
     NotEnoughTokens,
 )
+from ..storage_common import (
+    get_configured_shares_needed,
+    get_configured_shares_total,
+    get_configured_pass_value,
+    get_configured_allowed_public_keys,
+)
 from .._storage_client import (
     call_with_passes,
 )
+
 from .._storage_server import (
     _ValidationResult,
 )
-
+from .matchers import (
+    even,
+    odd,
+    raises,
+)
+from .strategies import (
+    pass_counts,
+    dummy_ristretto_keys,
+)
 from .storage_common import (
     pass_factory,
     integer_passes,
 )
 
 
+
+class GetConfiguredValueTests(TestCase):
+    """
+    Tests for helpers for reading certain configuration values.
+    """
+    @given(integers(min_value=1, max_value=255))
+    def test_get_configured_shares_needed(self, expected):
+        """
+        ``get_configured_shares_needed`` reads the ``shares.needed`` value from
+        the ``client`` section as an integer.
+        """
+        config = config_from_string(
+            "",
+            "",
+            """\
+[client]
+shares.needed = {}
+shares.happy = 5
+shares.total = 10
+""".format(expected),
+        )
+
+        self.assertThat(
+            get_configured_shares_needed(config),
+            Equals(expected),
+        )
+
+    @given(integers(min_value=1, max_value=255))
+    def test_get_configured_shares_total(self, expected):
+        """
+        ``get_configured_shares_total`` reads the ``shares.total`` value from
+        the ``client`` section as an integer.
+        """
+        config = config_from_string(
+            "",
+            "",
+            """\
+[client]
+shares.needed = 5
+shares.happy = 5
+shares.total = {}
+""".format(expected),
+        )
+
+        self.assertThat(
+            get_configured_shares_total(config),
+            Equals(expected),
+        )
+
+    @given(integers(min_value=1, max_value=10000000))
+    def test_get_configured_pass_value(self, expected):
+        """
+        ``get_configured_pass_value`` reads the ``pass-value`` value from the
+        ``storageclient.plugins.privatestorageio-zkapauthz-v1`` section as an
+        integer.
+        """
+        config = config_from_string(
+            "",
+            "",
+            """\
+[client]
+shares.needed = 3
+shares.happy = 5
+shares.total = 10
+
+[storageclient.plugins.privatestorageio-zkapauthz-v1]
+pass-value={}
+""".format(expected),
+        )
+
+        self.assertThat(
+            get_configured_pass_value(config),
+            Equals(expected),
+        )
+
+    @given(sets(dummy_ristretto_keys(), min_size=1, max_size=10))
+    def test_get_configured_allowed_public_keys(self, expected):
+        """
+        ``get_configured_pass_value`` reads the ``pass-value`` value from the
+        ``storageclient.plugins.privatestorageio-zkapauthz-v1`` section as an
+        integer.
+        """
+        config = config_from_string(
+            "",
+            "",
+            """\
+[client]
+shares.needed = 3
+shares.happy = 5
+shares.total = 10
+
+[storageclient.plugins.privatestorageio-zkapauthz-v1]
+allowed-public-keys = {}
+""".format(",".join(expected)),
+        )
+
+        self.assertThat(
+            get_configured_allowed_public_keys(config),
+            Equals(expected),
+        )
+
+
 class CallWithPassesTests(TestCase):
     """
     Tests for ``call_with_passes``.