diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst
index b3e672fe52b76bc335481ad7adfe4e76d39cb974..5f27eb1a7b5915dbe01785bf60f2fc8360655f91 100644
--- a/docs/source/configuration.rst
+++ b/docs/source/configuration.rst
@@ -42,6 +42,13 @@ The client can also be configured with the value of a single pass::
 
 The value given here must agree with the value servers use in their configuration or the storage service will be unusable.
 
+The client can also be configured with the number of passes to expect in exchange for one voucher::
+
+  [storageclient.plugins.privatestorageio-zkapauthz-v1]
+  default-token-count = 32768
+
+The value given here must agree with the value the issuer uses in its configuration or redemption may fail.
+
 Server
 ------
 
diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py
index 1b8874e4a7a8353c7112906b2459a85f710efa60..1ce34fed2774c545296ecf5ad330a32c8c3e2281 100644
--- a/src/_zkapauthorizer/_plugin.py
+++ b/src/_zkapauthorizer/_plugin.py
@@ -185,17 +185,12 @@ class ZKAPAuthorizer(object):
         )
 
 
-    def get_client_resource(self, node_config, default_token_count=None, reactor=None):
+    def get_client_resource(self, node_config, reactor=None):
         """
         Get an ``IZKAPRoot`` for the given node configuration.
 
         :param allmydata.node._Config node_config: The configuration object
             for the relevant node.
-
-        :param int default_token_count: Configure the payment controller with
-            a default number of tokens to request during voucher redemption.
-            This is only used if a number of tokens isn't specified at the
-            point of redemption.
         """
         if reactor is None:
             from twisted.internet import reactor
@@ -203,7 +198,6 @@ class ZKAPAuthorizer(object):
             node_config,
             store=self._get_store(node_config),
             redeemer=self._get_redeemer(node_config, None, reactor),
-            default_token_count=default_token_count,
             clock=reactor,
         )
 
diff --git a/src/_zkapauthorizer/configutil.py b/src/_zkapauthorizer/configutil.py
new file mode 100644
index 0000000000000000000000000000000000000000..d87772d9052f63157a0bb83235a1bb5004eb5e9d
--- /dev/null
+++ b/src/_zkapauthorizer/configutil.py
@@ -0,0 +1,75 @@
+# Copyright 2021 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Basic utilities related to the Tahoe configuration file.
+"""
+
+from __future__ import (
+    division,
+    absolute_import,
+    print_function,
+    unicode_literals,
+)
+
+
+def _merge_dictionaries(dictionaries):
+    """
+    Collapse a sequence of dictionaries into one, with collisions resolved by
+    taking the value from later dictionaries in the sequence.
+
+    :param [dict] dictionaries: The dictionaries to collapse.
+
+    :return dict: The collapsed dictionary.
+    """
+    result = {}
+    for d in dictionaries:
+        result.update(d)
+    return result
+
+
+def _tahoe_config_quote(text):
+    """
+    Quote **%** in a unicode string.
+
+    :param unicode text: The string on which to perform quoting.
+
+    :return unicode: The string with ``%%`` replacing ``%``.
+    """
+    return text.replace("%", "%%")
+
+
+def config_string_from_sections(divided_sections):
+    """
+    Get the .ini-syntax unicode string representing the given configuration
+    values.
+
+    :param [dict] divided_sections: The configuration to use to generate the
+        string.  Each ``dict`` maps a top-level section name to a ``dict`` of
+        key/value pairs.  Dictionaries may have overlapping top-level
+        sections, in which case the section items are merged (for collisions,
+        last value wins).
+    """
+    sections = _merge_dictionaries(divided_sections)
+    return "".join(list(
+        "[{name}]\n{items}\n".format(
+            name=name,
+            items="\n".join(
+                "{key} = {value}".format(key=key, value=_tahoe_config_quote(value))
+                for (key, value)
+                in contents.items()
+            )
+        )
+        for (name, contents) in sections.items()
+    ))
diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index f8fa7a9dab92f86bba84ece5a21a0e1cfeb603e4..e5e31eae14212a1b9d9f9ad716e96ae27460d786 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -91,11 +91,33 @@ class IZKAPRoot(IResource):
     controller = Attribute("The ``PaymentController`` used by this resource tree.")
 
 
+def get_token_count(
+        plugin_name,
+        node_config,
+):
+    """
+    Retrieve the configured voucher value, in number of tokens, from the given
+    configuration.
+
+    :param unicode plugin_name: The plugin name to use to choose a
+        configuration section.
+
+    :param _Config node_config: See ``from_configuration``.
+
+    :param int default: The value to return if none is configured.
+    """
+    section_name = u"storageclient.plugins.{}".format(plugin_name)
+    return int(node_config.get_config(
+        section=section_name,
+        option=u"default-token-count",
+        default=NUM_TOKENS,
+    ))
+
+
 def from_configuration(
         node_config,
         store,
         redeemer=None,
-        default_token_count=None,
         clock=None,
 ):
     """
@@ -114,22 +136,23 @@ def from_configuration(
     :param IRedeemer redeemer: The voucher redeemer to use.  If ``None`` a
         sensible one is constructed.
 
-    :param default_token_count: See ``PaymentController.default_token_count``.
-
     :param clock: See ``PaymentController._clock``.
 
     :return IZKAPRoot: The root of the resource hierarchy presented by the
         client side of the plugin.
     """
+    plugin_name = u"privatestorageio-zkapauthz-v1"
     if redeemer is None:
         redeemer = get_redeemer(
-            u"privatestorageio-zkapauthz-v1",
+            plugin_name,
             node_config,
             None,
             None,
         )
-    if default_token_count is None:
-        default_token_count = NUM_TOKENS
+    default_token_count = get_token_count(
+        plugin_name,
+        node_config,
+    )
     controller = PaymentController(
         store,
         redeemer,
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index a54282ecf2f3ed812e7635fb36af59ec90b678db..a1a24b10e9f1b4f8967e65b7825816594e17166e 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -84,6 +84,10 @@ from ..model import (
     Redeemed,
 )
 
+from ..configutil import (
+    config_string_from_sections,
+)
+
 # Sizes informed by
 # https://github.com/brave-intl/challenge-bypass-ristretto/blob/2f98b057d7f353c12b2b12d0f5ae9ad115f1d0ba/src/oprf.rs#L18-L33
 
@@ -96,32 +100,6 @@ _UNBLINDED_TOKEN_LENGTH = 96
 # The length of a `VerificationSignature`, in bytes.
 _VERIFICATION_SIGNATURE_LENGTH = 64
 
-def _merge_dictionaries(dictionaries):
-    result = {}
-    for d in dictionaries:
-        result.update(d)
-    return result
-
-
-def _tahoe_config_quote(text):
-    return text.replace(u"%", u"%%")
-
-
-def _config_string_from_sections(divided_sections):
-    sections = _merge_dictionaries(divided_sections)
-    return u"".join(list(
-        u"[{name}]\n{items}\n".format(
-            name=name,
-            items=u"\n".join(
-                u"{key} = {value}".format(key=key, value=_tahoe_config_quote(value))
-                for (key, value)
-                in contents.items()
-            )
-        )
-        for (name, contents) in sections.items()
-    ))
-
-
 def tahoe_config_texts(storage_client_plugins, shares):
     """
     Build the text of complete Tahoe-LAFS configurations for a node.
@@ -153,7 +131,7 @@ def tahoe_config_texts(storage_client_plugins, shares):
     )
 
     return builds(
-        lambda *sections: _config_string_from_sections(
+        lambda *sections: config_string_from_sections(
             sections,
         ),
         fixed_dictionaries(
@@ -270,12 +248,21 @@ def client_dummyredeemer_configurations():
     })
 
 
-def client_doublespendredeemer_configurations():
+def token_counts():
+    """
+    Build integers that are plausible as a number of tokens to receive in
+    exchange for a voucher.
+    """
+    return integers(min_value=16, max_value=2 ** 16)
+
+
+def client_doublespendredeemer_configurations(default_token_counts=token_counts()):
     """
     Build DoubleSpendRedeemer-using configuration values for the client-side plugin.
     """
-    return just({
-        u"redeemer": u"double-spend",
+    return fixed_dictionaries({
+        u"redeemer": just(u"double-spend"),
+        u"default-token-count": default_token_counts.map(str),
     })
 
 
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index 6b38da748c5e7ce8f541b72698f79c974cfab177..233f404b2668696edb0380b2d4d45c81f5415aa8 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -80,6 +80,7 @@ from hypothesis import (
 )
 from hypothesis.strategies import (
     one_of,
+    none,
     just,
     fixed_dictionaries,
     sampled_from,
@@ -128,6 +129,10 @@ from treq.testing import (
     RequestTraversalAgent,
 )
 
+from allmydata.client import (
+    config_from_string,
+)
+
 from .. import (
     __version__ as zkapauthorizer_version,
 )
@@ -143,12 +148,17 @@ from ..model import (
     memory_connect,
 )
 from ..resource import (
+    NUM_TOKENS,
     from_configuration,
+    get_token_count,
 )
 
 from ..pricecalculator import (
     PriceCalculator,
 )
+from ..configutil import (
+    config_string_from_sections,
+)
 
 from ..storage_common import (
     required_passes,
@@ -157,6 +167,7 @@ from ..storage_common import (
 )
 
 from .strategies import (
+    direct_tahoe_configs,
     tahoe_configs,
     client_unpaidredeemer_configurations,
     client_doublespendredeemer_configurations,
@@ -179,9 +190,6 @@ from .json import (
     loads,
 )
 
-# A small number of tokens to work with in the tests.
-NUM_TOKENS = 100
-
 TRANSIENT_ERROR = u"something went wrong, who knows what"
 
 # Helper to work-around https://github.com/twisted/treq/issues/161
@@ -278,7 +286,6 @@ def root_from_config(config, now):
             now,
             memory_connect,
         ),
-        default_token_count=NUM_TOKENS,
         clock=Clock(),
     )
 
@@ -337,12 +344,60 @@ def get_config_with_api_token(tempdir, get_config, api_auth_token):
     :param bytes api_auth_token: The HTTP API authorization token to write to
         the node directory.
     """
-    FilePath(tempdir.join(b"tahoe", b"private")).makedirs()
-    config = get_config(tempdir.join(b"tahoe"), b"tub.port")
-    config.write_private_config(b"api_auth_token", api_auth_token)
+    basedir = tempdir.join(b"tahoe")
+    config = get_config(basedir, b"tub.port")
+    add_api_token_to_config(
+        basedir,
+        config,
+        api_auth_token,
+    )
     return config
 
 
+def add_api_token_to_config(basedir, config, api_auth_token):
+    """
+    Create a private directory beneath the given base directory, point the
+    given config at it, and write the given API auth token to it.
+    """
+    FilePath(basedir).child(b"private").makedirs()
+    config._basedir = basedir
+    config.write_private_config(b"api_auth_token", api_auth_token)
+
+
+class GetTokenCountTests(TestCase):
+    """
+    Tests for ``get_token_count``.
+    """
+    @given(one_of(none(), integers(min_value=16)))
+    def test_get_token_count(self, token_count):
+        """
+        ``get_token_count`` returns the integer value of the
+        ``default-token-count`` item from the given configuration object.
+        """
+        plugin_name = u"hello-world"
+        if token_count is None:
+            expected_count = NUM_TOKENS
+            token_config = {}
+        else:
+            expected_count = token_count
+            token_config = {
+                u"default-token-count": u"{}".format(expected_count)
+            }
+
+        config_text = _config_string_from_sections([{
+            u"storageclient.plugins." + plugin_name: token_config,
+        }])
+        node_config = config_from_string(
+            self.useFixture(TempDir()).join(b"tahoe"),
+            u"tub.port",
+            config_text.encode("utf-8"),
+        )
+        self.assertThat(
+            get_token_count(plugin_name, node_config),
+            Equals(expected_count),
+        )
+
+
 class ResourceTests(TestCase):
     """
     General tests for the resources exposed by the plugin.
@@ -1010,26 +1065,27 @@ class VoucherTests(TestCase):
         )
 
     @given(
-        tahoe_configs(client_nonredeemer_configurations()),
+        direct_tahoe_configs(client_nonredeemer_configurations()),
         api_auth_tokens(),
         datetimes(),
         vouchers(),
     )
-    def test_get_known_voucher_redeeming(self, get_config, api_auth_token, now, voucher):
+    def test_get_known_voucher_redeeming(self, config, api_auth_token, now, voucher):
         """
         When a voucher is first ``PUT`` and then later a ``GET`` is issued for the
         same voucher then the response code is **OK** and details, including
         those relevant to a voucher which is actively being redeemed, about
         the voucher are included in a json-encoded response body.
         """
+        count = get_token_count("privatestorageio-zkapauthz-v1", config)
         return self._test_get_known_voucher(
-            get_config,
+            config,
             api_auth_token,
             now,
             voucher,
             MatchesStructure(
                 number=Equals(voucher),
-                expected_tokens=Equals(NUM_TOKENS),
+                expected_tokens=Equals(count),
                 created=Equals(now),
                 state=Equals(Redeeming(
                     started=now,
@@ -1039,42 +1095,43 @@ class VoucherTests(TestCase):
         )
 
     @given(
-        tahoe_configs(client_dummyredeemer_configurations()),
+        direct_tahoe_configs(client_dummyredeemer_configurations()),
         api_auth_tokens(),
         datetimes(),
         vouchers(),
     )
-    def test_get_known_voucher_redeemed(self, get_config, api_auth_token, now, voucher):
+    def test_get_known_voucher_redeemed(self, config, api_auth_token, now, voucher):
         """
         When a voucher is first ``PUT`` and then later a ``GET`` is issued for the
         same voucher then the response code is **OK** and details, including
         those relevant to a voucher which has been redeemed, about the voucher
         are included in a json-encoded response body.
         """
+        count = get_token_count("privatestorageio-zkapauthz-v1", config)
         return self._test_get_known_voucher(
-            get_config,
+            config,
             api_auth_token,
             now,
             voucher,
             MatchesStructure(
                 number=Equals(voucher),
-                expected_tokens=Equals(NUM_TOKENS),
+                expected_tokens=Equals(count),
                 created=Equals(now),
                 state=Equals(Redeemed(
                     finished=now,
-                    token_count=NUM_TOKENS,
+                    token_count=count,
                     public_key=None,
                 )),
             ),
         )
 
     @given(
-        tahoe_configs(client_doublespendredeemer_configurations()),
+        direct_tahoe_configs(client_doublespendredeemer_configurations()),
         api_auth_tokens(),
         datetimes(),
         vouchers(),
     )
-    def test_get_known_voucher_doublespend(self, get_config, api_auth_token, now, voucher):
+    def test_get_known_voucher_doublespend(self, config, api_auth_token, now, voucher):
         """
         When a voucher is first ``PUT`` and then later a ``GET`` is issued for the
         same voucher then the response code is **OK** and details, including
@@ -1082,14 +1139,15 @@ class VoucherTests(TestCase):
         already redeemed, about the voucher are included in a json-encoded
         response body.
         """
+        count = get_token_count("privatestorageio-zkapauthz-v1", config)
         return self._test_get_known_voucher(
-            get_config,
+            config,
             api_auth_token,
             now,
             voucher,
             MatchesStructure(
                 number=Equals(voucher),
-                expected_tokens=Equals(NUM_TOKENS),
+                expected_tokens=Equals(count),
                 created=Equals(now),
                 state=Equals(DoubleSpend(
                     finished=now,
@@ -1098,12 +1156,12 @@ class VoucherTests(TestCase):
         )
 
     @given(
-        tahoe_configs(client_unpaidredeemer_configurations()),
+        direct_tahoe_configs(client_unpaidredeemer_configurations()),
         api_auth_tokens(),
         datetimes(),
         vouchers(),
     )
-    def test_get_known_voucher_unpaid(self, get_config, api_auth_token, now, voucher):
+    def test_get_known_voucher_unpaid(self, config, api_auth_token, now, voucher):
         """
         When a voucher is first ``PUT`` and then later a ``GET`` is issued for the
         same voucher then the response code is **OK** and details, including
@@ -1111,14 +1169,15 @@ class VoucherTests(TestCase):
         not been paid for yet, about the voucher are included in a
         json-encoded response body.
         """
+        count = get_token_count("privatestorageio-zkapauthz-v1", config)
         return self._test_get_known_voucher(
-            get_config,
+            config,
             api_auth_token,
             now,
             voucher,
             MatchesStructure(
                 number=Equals(voucher),
-                expected_tokens=Equals(NUM_TOKENS),
+                expected_tokens=Equals(count),
                 created=Equals(now),
                 state=Equals(Unpaid(
                     finished=now,
@@ -1127,12 +1186,12 @@ class VoucherTests(TestCase):
         )
 
     @given(
-        tahoe_configs(client_errorredeemer_configurations(TRANSIENT_ERROR)),
+        direct_tahoe_configs(client_errorredeemer_configurations(TRANSIENT_ERROR)),
         api_auth_tokens(),
         datetimes(),
         vouchers(),
     )
-    def test_get_known_voucher_error(self, get_config, api_auth_token, now, voucher):
+    def test_get_known_voucher_error(self, config, api_auth_token, now, voucher):
         """
         When a voucher is first ``PUT`` and then later a ``GET`` is issued for the
         same voucher then the response code is **OK** and details, including
@@ -1140,14 +1199,15 @@ class VoucherTests(TestCase):
         kind of transient conditions, about the voucher are included in a
         json-encoded response body.
         """
+        count = get_token_count("privatestorageio-zkapauthz-v1", config)
         return self._test_get_known_voucher(
-            get_config,
+            config,
             api_auth_token,
             now,
             voucher,
             MatchesStructure(
                 number=Equals(voucher),
-                expected_tokens=Equals(NUM_TOKENS),
+                expected_tokens=Equals(count),
                 created=Equals(now),
                 state=Equals(Error(
                     finished=now,
@@ -1156,7 +1216,7 @@ class VoucherTests(TestCase):
             ),
         )
 
-    def _test_get_known_voucher(self, get_config, api_auth_token, now, voucher, voucher_matcher):
+    def _test_get_known_voucher(self, config, api_auth_token, now, voucher, voucher_matcher):
         """
         Assert that a voucher that is ``PUT`` and then ``GET`` is represented in
         the JSON response.
@@ -1164,9 +1224,9 @@ class VoucherTests(TestCase):
         :param voucher_matcher: A matcher which matches the voucher expected
             to be returned by the ``GET``.
         """
-        config = get_config_with_api_token(
-            self.useFixture(TempDir()),
-            get_config,
+        add_api_token_to_config(
+            self.useFixture(TempDir()).join(b"tahoe"),
+            config,
             api_auth_token,
         )
         root = root_from_config(config, lambda: now)
@@ -1215,18 +1275,19 @@ class VoucherTests(TestCase):
         )
 
     @given(
-        tahoe_configs(),
+        direct_tahoe_configs(),
         api_auth_tokens(),
         datetimes(),
         lists(vouchers(), unique=True),
     )
-    def test_list_vouchers(self, get_config, api_auth_token, now, vouchers):
+    def test_list_vouchers(self, config, api_auth_token, now, vouchers):
         """
         A ``GET`` to the ``VoucherCollection`` itself returns a list of existing
         vouchers.
         """
+        count = get_token_count("privatestorageio-zkapauthz-v1", config)
         return self._test_list_vouchers(
-            get_config,
+            config,
             api_auth_token,
             now,
             vouchers,
@@ -1234,11 +1295,11 @@ class VoucherTests(TestCase):
                 u"vouchers": list(
                     Voucher(
                         number=voucher,
-                        expected_tokens=NUM_TOKENS,
+                        expected_tokens=count,
                         created=now,
                         state=Redeemed(
                             finished=now,
-                            token_count=NUM_TOKENS,
+                            token_count=count,
                             public_key=None,
                         ),
                     ).marshal()
@@ -1249,18 +1310,19 @@ class VoucherTests(TestCase):
         )
 
     @given(
-        tahoe_configs(client_unpaidredeemer_configurations()),
+        direct_tahoe_configs(client_unpaidredeemer_configurations()),
         api_auth_tokens(),
         datetimes(),
         lists(vouchers(), unique=True),
     )
-    def test_list_vouchers_transient_states(self, get_config, api_auth_token, now, vouchers):
+    def test_list_vouchers_transient_states(self, config, api_auth_token, now, vouchers):
         """
         A ``GET`` to the ``VoucherCollection`` itself returns a list of existing
         vouchers including state information that reflects transient states.
         """
+        count = get_token_count("privatestorageio-zkapauthz-v1", config)
         return self._test_list_vouchers(
-            get_config,
+            config,
             api_auth_token,
             now,
             vouchers,
@@ -1268,7 +1330,7 @@ class VoucherTests(TestCase):
                 u"vouchers": list(
                     Voucher(
                         number=voucher,
-                        expected_tokens=NUM_TOKENS,
+                        expected_tokens=count,
                         created=now,
                         state=Unpaid(
                             finished=now,
@@ -1280,14 +1342,14 @@ class VoucherTests(TestCase):
             }),
         )
 
-    def _test_list_vouchers(self, get_config, api_auth_token, now, vouchers, match_response_object):
-        config = get_config_with_api_token(
+    def _test_list_vouchers(self, config, api_auth_token, now, vouchers, match_response_object):
+        add_api_token_to_config(
             # Hypothesis causes our test case instances to be re-used many
             # times between setUp and tearDown.  Avoid re-using the same
             # temporary directory for every Hypothesis iteration because this
             # test leaves state behind that invalidates future iterations.
-            self.useFixture(TempDir()),
-            get_config,
+            self.useFixture(TempDir()).join(b"tahoe"),
+            config,
             api_auth_token,
         )
         root = root_from_config(config, lambda: now)
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index e0c4e8caae5a98f1f1d123e917044b79d0b321ad..0349dde406b8b78cc06b452b57ac8647d6a7b2dd 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -551,7 +551,6 @@ class ClientResourceTests(TestCase):
         self.assertThat(
             storage_server.get_client_resource(
                 config,
-                default_token_count=10,
                 reactor=Clock(),
             ),
             Provides([IResource]),