diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py
index ee57951537cb0800d74412e9e8579f0a7ac279df..4e19a3973db20994f044f5cf6b199a87479a6723 100644
--- a/src/_zkapauthorizer/_plugin.py
+++ b/src/_zkapauthorizer/_plugin.py
@@ -45,11 +45,6 @@ from twisted.internet.defer import (
     succeed,
 )
 
-from eliot import (
-    MessageType,
-    Field,
-)
-
 from allmydata.interfaces import (
     IFoolscapStoragePlugin,
     IAnnounceableStorageServer,
@@ -69,6 +64,10 @@ from .api import (
     ZKAPAuthorizerStorageClient,
 )
 
+from .eliot import (
+    GET_PASSES,
+)
+
 from .model import (
     VoucherStore,
 )
@@ -91,24 +90,6 @@ from .lease_maintenance import (
 
 _log = Logger()
 
-PRIVACYPASS_MESSAGE = Field(
-    u"message",
-    unicode,
-    u"The PrivacyPass request-binding data associated with a pass.",
-)
-
-PASS_COUNT = Field(
-    u"count",
-    int,
-    u"A number of passes.",
-)
-
-GET_PASSES = MessageType(
-    u"zkapauthorizer:get-passes",
-    [PRIVACYPASS_MESSAGE, PASS_COUNT],
-    u"Passes are being spent.",
-)
-
 @implementer(IAnnounceableStorageServer)
 @attr.s
 class AnnounceableStorageServer(object):
diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py
index 6559b732e6a1bcd67396dea3d561162f2ce31c5c..2c5a7af7393eb6677b86e99222f5d5131d3e94f8 100644
--- a/src/_zkapauthorizer/_storage_client.py
+++ b/src/_zkapauthorizer/_storage_client.py
@@ -20,20 +20,41 @@ This is the client part of a storage access protocol.  The server part is
 implemented in ``_storage_server.py``.
 """
 
+from __future__ import (
+    absolute_import,
+)
+
+from functools import (
+    partial,
+    wraps,
+)
+
 import attr
 
 from zope.interface import (
     implementer,
 )
+
+from eliot.twisted import (
+    DeferredContext,
+)
+
 from twisted.internet.defer import (
     inlineCallbacks,
     returnValue,
+    maybeDeferred,
 )
 from allmydata.interfaces import (
     IStorageServer,
 )
 
+from .eliot import (
+    SIGNATURE_CHECK_FAILED,
+    CALL_WITH_PASSES,
+)
+
 from .storage_common import (
+    MorePassesRequired,
     pass_value_attribute,
     required_passes,
     allocate_buckets_message,
@@ -44,7 +65,6 @@ from .storage_common import (
     get_required_new_passes_for_mutable_write,
 )
 
-
 class IncorrectStorageServerReference(Exception):
     """
     A Foolscap remote object which should reference a ZKAPAuthorizer storage
@@ -64,6 +84,71 @@ class IncorrectStorageServerReference(Exception):
         )
 
 
+def call_with_passes(method, num_passes, get_passes):
+    """
+    Call a method, passing the requested number of passes as the first
+    argument, and try again if the call fails with an error related to some of
+    the passes being rejected.
+
+    :param method: A callable which accepts a list of encoded passes as its
+        only argument and returns a ``Deferred``.  If the ``Deferred`` fires
+        with ``MorePassesRequired`` then the invalid passes will be discarded
+        and replacement passes will be requested for a new call of ``method``.
+        This will repeat until no passes remain, the method succeeds, or the
+        methods fails in a different way.
+
+    :param int num_passes: The number of passes to pass to the call.
+
+    :param (unicode -> int -> [bytes]) get_passes: A function for getting
+        passes.
+
+    :return: Whatever ``method`` returns.
+    """
+    def get_more_passes(reason):
+        reason.trap(MorePassesRequired)
+        num_failed = len(reason.value.signature_check_failed)
+        if num_failed == 0:
+            # If no signature checks failed then the call just didn't supply
+            # enough passes.  The exception tells us how many passes we should
+            # spend so we could try again with that number of passes but for
+            # now we'll just let the exception propagate.  The client should
+            # always figure out the number of passes right on the first try so
+            # this case is somewhat suspicious.  Err on the side of lack of
+            # service instead of burning extra passes.
+            return reason
+        SIGNATURE_CHECK_FAILED.log(count=num_failed)
+        new_passes = get_passes(num_failed)
+        for idx, new_pass in zip(reason.value.signature_check_failed, new_passes):
+            passes[idx] = new_pass
+        return go(passes)
+
+    def go(passes):
+        # Capture the Eliot context for the errback.
+        d = DeferredContext(maybeDeferred(method, passes))
+        d.addErrback(get_more_passes)
+        # Return the underlying Deferred without finishing the action.
+        return d.result
+
+    with CALL_WITH_PASSES(count=num_passes).context():
+        passes = get_passes(num_passes)
+        # Finish the Eliot action when this is done.
+        return DeferredContext(go(passes)).addActionFinish()
+
+
+def with_rref(f):
+    """
+    Decorate a function so that it automatically receives a
+    ``RemoteReference`` as its first argument when called.
+
+    The ``RemoteReference`` is retrieved by calling ``_rref`` on the first
+    argument passed to the function (expected to be ``self``).
+    """
+    @wraps(f)
+    def g(self, *args, **kwargs):
+        return f(self, self._rref(), *args, **kwargs)
+    return g
+
+
 @implementer(IStorageServer)
 @attr.s
 class ZKAPAuthorizerStorageClient(object):
@@ -96,7 +181,6 @@ class ZKAPAuthorizerStorageClient(object):
     _get_rref = attr.ib()
     _get_passes = attr.ib()
 
-    @property
     def _rref(self):
         rref = self._get_rref()
         # rref provides foolscap.ipb.IRemoteReference but in practice it is a
@@ -130,13 +214,16 @@ class ZKAPAuthorizerStorageClient(object):
             in self._get_passes(message.encode("utf-8"), count)
         )
 
-    def get_version(self):
-        return self._rref.callRemote(
+    @with_rref
+    def get_version(self, rref):
+        return rref.callRemote(
             "get_version",
         )
 
+    @with_rref
     def allocate_buckets(
             self,
+            rref,
             storage_index,
             renew_secret,
             cancel_secret,
@@ -144,92 +231,107 @@ class ZKAPAuthorizerStorageClient(object):
             allocated_size,
             canary,
     ):
-        return self._rref.callRemote(
-            "allocate_buckets",
-            self._get_encoded_passes(
-                allocate_buckets_message(storage_index),
-                required_passes(self._pass_value, [allocated_size] * len(sharenums)),
+        message = allocate_buckets_message(storage_index)
+        num_passes = required_passes(self._pass_value, [allocated_size] * len(sharenums))
+        return call_with_passes(
+            lambda passes: rref.callRemote(
+                "allocate_buckets",
+                passes,
+                storage_index,
+                renew_secret,
+                cancel_secret,
+                sharenums,
+                allocated_size,
+                canary,
             ),
-            storage_index,
-            renew_secret,
-            cancel_secret,
-            sharenums,
-            allocated_size,
-            canary,
+            num_passes,
+            partial(self._get_encoded_passes, message),
         )
 
+    @with_rref
     def get_buckets(
             self,
+            rref,
             storage_index,
     ):
-        return self._rref.callRemote(
+        return rref.callRemote(
             "get_buckets",
             storage_index,
         )
 
     @inlineCallbacks
+    @with_rref
     def add_lease(
             self,
+            rref,
             storage_index,
             renew_secret,
             cancel_secret,
     ):
-        share_sizes = (yield self._rref.callRemote(
+        share_sizes = (yield rref.callRemote(
             "share_sizes",
             storage_index,
             None,
         )).values()
         num_passes = required_passes(self._pass_value, share_sizes)
-        # print("Adding lease to {!r} with sizes {} with {} passes".format(
-        #     storage_index,
-        #     share_sizes,
-        #     num_passes,
-        # ))
-        returnValue((
-            yield self._rref.callRemote(
+
+        result = yield call_with_passes(
+            lambda passes: rref.callRemote(
                 "add_lease",
-                self._get_encoded_passes(add_lease_message(storage_index), num_passes),
+                passes,
                 storage_index,
                 renew_secret,
                 cancel_secret,
-            )
-        ))
+            ),
+            num_passes,
+            partial(self._get_encoded_passes, add_lease_message(storage_index)),
+        )
+        returnValue(result)
 
     @inlineCallbacks
+    @with_rref
     def renew_lease(
             self,
+            rref,
             storage_index,
             renew_secret,
     ):
-        share_sizes = (yield self._rref.callRemote(
+        share_sizes = (yield rref.callRemote(
             "share_sizes",
             storage_index,
             None,
         )).values()
         num_passes = required_passes(self._pass_value, share_sizes)
-        returnValue((
-            yield self._rref.callRemote(
+
+        result = yield call_with_passes(
+            lambda passes: rref.callRemote(
                 "renew_lease",
-                self._get_encoded_passes(renew_lease_message(storage_index), num_passes),
+                passes,
                 storage_index,
                 renew_secret,
-            )
-        ))
+            ),
+            num_passes,
+            partial(self._get_encoded_passes, renew_lease_message(storage_index)),
+        )
+        returnValue(result)
 
-    def stat_shares(self, storage_indexes):
-        return self._rref.callRemote(
+    @with_rref
+    def stat_shares(self, rref, storage_indexes):
+        return rref.callRemote(
             "stat_shares",
             storage_indexes,
         )
 
+    @with_rref
     def advise_corrupt_share(
             self,
+            rref,
             share_type,
             storage_index,
             shnum,
             reason,
     ):
-        return self._rref.callRemote(
+        return rref.callRemote(
             "advise_corrupt_share",
             share_type,
             storage_index,
@@ -238,15 +340,17 @@ class ZKAPAuthorizerStorageClient(object):
         )
 
     @inlineCallbacks
+    @with_rref
     def slot_testv_and_readv_and_writev(
             self,
+            rref,
             storage_index,
             secrets,
             tw_vectors,
             r_vector,
     ):
-        # Non-write operations on slots are free.
-        passes = []
+        # Read operations are free.
+        num_passes = 0
 
         if has_writes(tw_vectors):
             # When performing writes, if we're increasing the storage
@@ -258,43 +362,44 @@ class ZKAPAuthorizerStorageClient(object):
             # on the storage server that will give us a really good estimate
             # of the current size of all of the specified shares (keys of
             # tw_vectors).
-            current_sizes = yield self._rref.callRemote(
+            current_sizes = yield rref.callRemote(
                 "share_sizes",
                 storage_index,
                 set(tw_vectors),
             )
             # Determine the cost of the new storage for the operation.
-            required_new_passes = get_required_new_passes_for_mutable_write(
+            num_passes = get_required_new_passes_for_mutable_write(
                 self._pass_value,
                 current_sizes,
                 tw_vectors,
             )
-            # Prepare to pay it.
-            if required_new_passes:
-                passes = self._get_encoded_passes(
-                    slot_testv_and_readv_and_writev_message(storage_index),
-                    required_new_passes,
-                )
-
-        # Perform the operation with the passes we determined are required.
-        returnValue((
-            yield self._rref.callRemote(
+
+        result = yield call_with_passes(
+            lambda passes: rref.callRemote(
                 "slot_testv_and_readv_and_writev",
                 passes,
                 storage_index,
                 secrets,
                 tw_vectors,
                 r_vector,
-            )
-        ))
+            ),
+            num_passes,
+            partial(
+                self._get_encoded_passes,
+                slot_testv_and_readv_and_writev_message(storage_index),
+            ),
+        )
+        returnValue(result)
 
+    @with_rref
     def slot_readv(
             self,
+            rref,
             storage_index,
             shares,
             r_vector,
     ):
-        return self._rref.callRemote(
+        return rref.callRemote(
             "slot_readv",
             storage_index,
             shares,
diff --git a/src/_zkapauthorizer/_storage_server.py b/src/_zkapauthorizer/_storage_server.py
index 73753fe6c4d710af5e633c6ee683c3f195a034f0..bb89df0e0e07e5731120c73d41251aba54a8a98b 100644
--- a/src/_zkapauthorizer/_storage_server.py
+++ b/src/_zkapauthorizer/_storage_server.py
@@ -88,6 +88,7 @@ from .foolscap import (
     RIPrivacyPassAuthorizedStorageServer,
 )
 from .storage_common import (
+    MorePassesRequired,
     pass_value_attribute,
     required_passes,
     allocate_buckets_message,
@@ -102,26 +103,6 @@ from .storage_common import (
 SLOT_HEADER_SIZE = 468
 LEASE_TRAILER_SIZE = 4
 
-@attr.s
-class MorePassesRequired(Exception):
-    """
-    Storage operations fail with ``MorePassesRequired`` when they are not
-    accompanied by a sufficient number of valid passes.
-
-    :ivar int valid_count: The number of valid passes presented in the
-        operation.
-
-    ivar int required_count: The number of valid passes which must be
-        presented for the operation to be authorized.
-
-    :ivar list[int] signature_check_failed: Indices into the supplied list of
-        passes indicating passes which failed the signature check.
-    """
-    valid_count = attr.ib()
-    required_count = attr.ib()
-    signature_check_failed = attr.ib()
-
-
 @attr.s
 class _ValidationResult(object):
     """
diff --git a/src/_zkapauthorizer/api.py b/src/_zkapauthorizer/api.py
index 365e39a23026e1b7692267d12c895f45cfaeda64..70e4725e5c89d503e6973e2b708cecede48bcd66 100644
--- a/src/_zkapauthorizer/api.py
+++ b/src/_zkapauthorizer/api.py
@@ -20,8 +20,10 @@ __all__ = [
     "ZKAPAuthorizer",
 ]
 
-from ._storage_server import (
+from .storage_common import (
     MorePassesRequired,
+)
+from ._storage_server import (
     LeaseRenewalRequired,
     ZKAPAuthorizerStorageServer,
 )
diff --git a/src/_zkapauthorizer/eliot.py b/src/_zkapauthorizer/eliot.py
new file mode 100644
index 0000000000000000000000000000000000000000..da3960d07a9f2ef09fb112fed4dd33bae61bc545
--- /dev/null
+++ b/src/_zkapauthorizer/eliot.py
@@ -0,0 +1,58 @@
+# Copyright 2020 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.
+
+"""
+Eliot field, message, and action definitions for ZKAPAuthorizer.
+"""
+
+from __future__ import (
+    absolute_import,
+)
+
+from eliot import (
+    Field,
+    MessageType,
+    ActionType,
+)
+
+PRIVACYPASS_MESSAGE = Field(
+    u"message",
+    unicode,
+    u"The PrivacyPass request-binding data associated with a pass.",
+)
+
+PASS_COUNT = Field(
+    u"count",
+    int,
+    u"A number of passes.",
+)
+
+GET_PASSES = MessageType(
+    u"zkapauthorizer:get-passes",
+    [PRIVACYPASS_MESSAGE, PASS_COUNT],
+    u"Passes are being spent.",
+)
+
+SIGNATURE_CHECK_FAILED = MessageType(
+    u"zkapauthorizer:storage-client:signature-check-failed",
+    [PASS_COUNT],
+    u"Some passes the client tried to use were rejected for having invalid signatures.",
+)
+
+CALL_WITH_PASSES = ActionType(
+    u"zkapauthorizer:storage-client:call-with-passes",
+    [PASS_COUNT],
+    [],
+    u"A storage operation is being started which may spend some passes.",
+)
diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py
index f00997b1c6db9b240eebd7dade935c5c6dc8e917..80707f226b892c96cfa5fefd278e68b9146dc7e1 100644
--- a/src/_zkapauthorizer/storage_common.py
+++ b/src/_zkapauthorizer/storage_common.py
@@ -30,6 +30,26 @@ from .validators import (
     greater_than,
 )
 
+@attr.s(frozen=True)
+class MorePassesRequired(Exception):
+    """
+    Storage operations fail with ``MorePassesRequired`` when they are not
+    accompanied by a sufficient number of valid passes.
+
+    :ivar int valid_count: The number of valid passes presented in the
+        operation.
+
+    ivar int required_count: The number of valid passes which must be
+        presented for the operation to be authorized.
+
+    :ivar list[int] signature_check_failed: Indices into the supplied list of
+        passes indicating passes which failed the signature check.
+    """
+    valid_count = attr.ib()
+    required_count = attr.ib()
+    signature_check_failed = attr.ib(converter=frozenset)
+
+
 def _message_maker(label):
     def make_message(storage_index):
         return u"{label} {storage_index}".format(
diff --git a/src/_zkapauthorizer/tests/test_storage_client.py b/src/_zkapauthorizer/tests/test_storage_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..77018c2928f1c7cd991093ed416e12c5ce3a4dff
--- /dev/null
+++ b/src/_zkapauthorizer/tests/test_storage_client.py
@@ -0,0 +1,215 @@
+# Copyright 2020 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.
+
+"""
+Tests for ``_zkapauthorizer._storage_client``.
+"""
+
+import attr
+
+from itertools import (
+    count,
+    islice,
+)
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    Always,
+    Is,
+    Equals,
+    AfterPreprocessing,
+)
+from testtools.twistedsupport import (
+    succeeded,
+    failed,
+)
+
+from hypothesis import (
+    given,
+)
+from hypothesis.strategies import (
+    integers,
+)
+
+from twisted.internet.defer import (
+    succeed,
+    fail,
+)
+
+from ..api import (
+    MorePassesRequired,
+)
+
+from .._storage_client import (
+    call_with_passes,
+)
+from .._storage_server import (
+    _ValidationResult,
+)
+
+def pass_counts():
+    return integers(min_value=1, max_value=2 ** 8)
+
+
+def pass_factory():
+    return _PassFactory()
+
+@attr.s
+class _PassFactory(object):
+    """
+    A stateful pass issuer.
+
+    :ivar list spent: All of the passes ever issued.
+
+    :ivar _fountain: A counter for making each new pass issued unique.
+    """
+    spent = attr.ib(default=attr.Factory(list))
+
+    _fountain = attr.ib(default=attr.Factory(count))
+
+    def get(self, num_passes):
+        passes = list(islice(self._fountain, num_passes))
+        self.spent.extend(passes)
+        return passes
+
+
+class CallWithPassesTests(TestCase):
+    """
+    Tests for ``call_with_passes``.
+    """
+    @given(pass_counts())
+    def test_success_result(self, num_passes):
+        """
+        ``call_with_passes`` returns a ``Deferred`` that fires with the same
+        success result as that of the ``Deferred`` returned by the method
+        passed in.
+        """
+        result = object()
+        self.assertThat(
+            call_with_passes(
+                lambda passes: succeed(result),
+                num_passes,
+                pass_factory().get,
+            ),
+            succeeded(Is(result)),
+        )
+
+    @given(pass_counts())
+    def test_failure_result(self, num_passes):
+        """
+        ``call_with_passes`` returns a ``Deferred`` that fires with the same
+        failure result as that of the ``Deferred`` returned by the method
+        passed in if that failure is not a ``MorePassesRequired``.
+        """
+        result = Exception()
+        self.assertThat(
+            call_with_passes(
+                lambda passes: fail(result),
+                num_passes,
+                pass_factory().get,
+            ),
+            failed(
+                AfterPreprocessing(
+                    lambda f: f.value,
+                    Is(result),
+                ),
+            ),
+        )
+
+    @given(pass_counts())
+    def test_passes(self, num_passes):
+        """
+        ``call_with_passes`` calls the given method with a list of passes
+        containing ``num_passes`` created by the function passed for
+        ``get_passes``.
+        """
+        passes = pass_factory()
+
+        self.assertThat(
+            call_with_passes(
+                lambda passes: succeed(passes),
+                num_passes,
+                passes.get,
+            ),
+            succeeded(
+                Equals(
+                    passes.spent,
+                ),
+            ),
+        )
+
+    @given(pass_counts())
+    def test_retry_on_rejected_passes(self, num_passes):
+        """
+        ``call_with_passes`` tries calling the given method again with a new list
+        of passes, still of length ```num_passes``, but without the passes
+        which were rejected on the first try.
+        """
+        passes = pass_factory()
+
+        def reject_even_pass_values(passes):
+            good_passes = list(idx for (idx, p) in enumerate(passes) if p % 2)
+            bad_passes = list(idx for (idx, p) in enumerate(passes) if idx not in good_passes)
+            if len(good_passes) < num_passes:
+                _ValidationResult(
+                    valid=good_passes,
+                    signature_check_failed=bad_passes,
+                ).raise_for(num_passes)
+            return None
+
+        self.assertThat(
+            call_with_passes(
+                reject_even_pass_values,
+                num_passes,
+                passes.get,
+            ),
+            succeeded(Always()),
+        )
+
+    @given(pass_counts())
+    def test_pass_through_too_few_passes(self, num_passes):
+        """
+        ``call_with_passes`` lets ``MorePassesRequired`` propagate through it if
+        no passes have been marked as invalid.  This happens if all passes
+        given were valid but too fewer were given.
+        """
+        passes = pass_factory()
+
+        def reject_passes(passes):
+            _ValidationResult(
+                valid=range(len(passes)),
+                signature_check_failed=[],
+            ).raise_for(len(passes) + 1)
+
+        self.assertThat(
+            call_with_passes(
+                reject_passes,
+                num_passes,
+                passes.get,
+            ),
+            failed(
+                AfterPreprocessing(
+                    lambda f: f.value,
+                    Equals(
+                        MorePassesRequired(
+                            valid_count=num_passes,
+                            required_count=num_passes + 1,
+                            signature_check_failed=[],
+                        ),
+                    ),
+                ),
+            ),
+        )
diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py
index a267c0667f3fe1b3101ab9e5a7a9eb10d8091a32..bb79fb25e919110d0454595fe55cb81203dfbac9 100644
--- a/src/_zkapauthorizer/tests/test_storage_protocol.py
+++ b/src/_zkapauthorizer/tests/test_storage_protocol.py
@@ -52,6 +52,7 @@ from hypothesis.strategies import (
     lists,
     tuples,
     integers,
+    data as data_strategy,
 )
 
 from twisted.python.runtime import (
@@ -111,11 +112,13 @@ from .foolscap import (
     LocalRemote,
 )
 from ..api import (
+    MorePassesRequired,
     ZKAPAuthorizerStorageServer,
     ZKAPAuthorizerStorageClient,
 )
 from ..storage_common import (
     slot_testv_and_readv_and_writev_message,
+    allocate_buckets_message,
     get_implied_data_length,
     required_passes,
 )
@@ -164,6 +167,28 @@ class RequiredPassesTests(TestCase):
         )
 
 
+def get_passes(message, count, signing_key):
+    """
+    :param unicode message: Request-binding message for PrivacyPass.
+
+    :param int count: The number of passes to get.
+
+    :param SigningKEy signing_key: The key to use to sign the passes.
+
+    :return list[Pass]: ``count`` new random passes signed with the given key
+        and bound to the given message.
+    """
+    return list(
+        Pass(*pass_.split(u" "))
+        for pass_
+        in make_passes(
+            signing_key,
+            message,
+            list(RandomToken.create() for n in range(count)),
+        )
+    )
+
+
 class ShareTests(TestCase):
     """
     Tests for interaction with shares.
@@ -182,17 +207,10 @@ class ShareTests(TestCase):
         self.signing_key = random_signing_key()
         self.spent_passes = 0
 
-        def get_passes(message, count):
+        def counting_get_passes(message, count):
             self.spent_passes += count
-            return list(
-                Pass(*pass_.split(u" "))
-                for pass_
-                in make_passes(
-                    self.signing_key,
-                    message,
-                    list(RandomToken.create() for n in range(count)),
-                )
-            )
+            return get_passes(message, count, self.signing_key)
+
         self.server = ZKAPAuthorizerStorageServer(
             self.anonymous_storage_server,
             self.pass_value,
@@ -202,7 +220,7 @@ class ShareTests(TestCase):
         self.client = ZKAPAuthorizerStorageClient(
             self.pass_value,
             get_rref=lambda: self.local_remote_server,
-            get_passes=get_passes,
+            get_passes=counting_get_passes,
         )
 
     def test_get_version(self):
@@ -215,6 +233,95 @@ class ShareTests(TestCase):
             succeeded(matches_version_dictionary()),
         )
 
+    @given(
+        storage_index=storage_indexes(),
+        renew_secret=lease_renew_secrets(),
+        cancel_secret=lease_cancel_secrets(),
+        sharenums=sharenum_sets(),
+        size=sizes(),
+        data=data_strategy(),
+    )
+    def test_rejected_passes_reported(self, storage_index, renew_secret, cancel_secret, sharenums, size, data):
+        """
+        Any passes rejected by the storage server are reported with a
+        ``MorePassesRequired`` exception sent to the client.
+        """
+        # Hypothesis causes our storage server to be used many times.  Clean
+        # up between iterations.
+        cleanup_storage_server(self.anonymous_storage_server)
+
+        num_passes = required_passes(self.pass_value, [size] * len(sharenums))
+
+        # Pick some passes to mess with.
+        bad_pass_indexes = data.draw(
+            lists(
+                integers(
+                    min_value=0,
+                    max_value=num_passes - 1,
+                ),
+                min_size=1,
+                max_size=num_passes,
+                unique=True,
+            ),
+        )
+
+        # Make some passes with a key untrusted by the server.
+        bad_passes = get_passes(
+            allocate_buckets_message(storage_index),
+            len(bad_pass_indexes),
+            random_signing_key(),
+        )
+
+        # Make some passes with a key trusted by the server.
+        good_passes = get_passes(
+            allocate_buckets_message(storage_index),
+            num_passes - len(bad_passes),
+            self.signing_key,
+        )
+
+        all_passes = []
+        for i in range(num_passes):
+            if i in bad_pass_indexes:
+                all_passes.append(bad_passes.pop())
+            else:
+                all_passes.append(good_passes.pop())
+
+        # Sanity checks
+        self.assertThat(bad_passes, Equals([]))
+        self.assertThat(good_passes, Equals([]))
+        self.assertThat(all_passes, HasLength(num_passes))
+
+        self.assertThat(
+            # Bypass the client handling of MorePassesRequired so we can see
+            # it.
+            self.local_remote_server.callRemote(
+                "allocate_buckets",
+                list(
+                    pass_.pass_text.encode("ascii")
+                    for pass_
+                    in all_passes
+                ),
+                storage_index,
+                renew_secret,
+                cancel_secret,
+                sharenums,
+                size,
+                canary=self.canary,
+            ),
+            failed(
+                AfterPreprocessing(
+                    lambda f: f.value,
+                    Equals(
+                        MorePassesRequired(
+                            valid_count=num_passes - len(bad_pass_indexes),
+                            required_count=num_passes,
+                            signature_check_failed=bad_pass_indexes,
+                        ),
+                    ),
+                ),
+            ),
+        )
+
     @given(
         storage_index=storage_indexes(),
         renew_secret=lease_renew_secrets(),
@@ -814,7 +921,7 @@ class ShareTests(TestCase):
         # The nice Python API doesn't let you do this so we drop down to
         # the layer below.  We also use positional arguments because they
         # transit the network differently from keyword arguments.  Yay.
-        d = self.client._rref.callRemote(
+        d = self.local_remote_server.callRemote(
             "slot_testv_and_readv_and_writev",
             # passes
             self.client._get_encoded_passes(