diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py
index 579312ea527aaa30a09304262aaf8fb55f63d1b0..182a739c3c171da1e6b45130ebca515d9eabc665 100644
--- a/src/_zkapauthorizer/_storage_client.py
+++ b/src/_zkapauthorizer/_storage_client.py
@@ -85,7 +85,7 @@ class ZKAPAuthorizerStorageClient(object):
         """
         assert isinstance(message, unicode)
         return list(
-            t.text.encode("ascii")
+            t.pass_text.encode("ascii")
             for t
             in self._get_passes(message.encode("utf-8"), count)
         )
diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 429805b5bb222830b5b3cd0b65bebe508712b74a..5539cea4e03f306f07a388a57a57a07315d05e85 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -33,7 +33,10 @@ from json import (
 from datetime import (
     timedelta,
 )
-
+from base64 import (
+    b64encode,
+    b64decode,
+)
 import attr
 
 from zope.interface import (
@@ -72,6 +75,10 @@ from treq.client import (
 
 import privacypass
 
+from ._base64 import (
+    urlsafe_b64decode,
+)
+
 from .model import (
     RandomToken,
     UnblindedToken,
@@ -177,12 +184,7 @@ class NonRedeemer(object):
         return cls()
 
     def random_tokens_for_voucher(self, voucher, count):
-        # It doesn't matter because we're never going to try to redeem them.
-        return list(
-            RandomToken(u"{}-{}".format(voucher.number, n))
-            for n
-            in range(count)
-        )
+        return dummy_random_tokens(voucher, count)
 
     def redeem(self, voucher, random_tokens):
         # Don't try to redeem them.
@@ -260,8 +262,17 @@ class UnpaidRedeemer(object):
 
 
 def dummy_random_tokens(voucher, count):
+    v = urlsafe_b64decode(voucher.number.encode("ascii"))
+    def dummy_random_token(n):
+        return RandomToken(
+            # Padding is 96 (random token length) - 32 (decoded voucher
+            # length)
+            b64encode(
+                v + u"{:0>64}".format(n).encode("ascii"),
+            ).decode("ascii"),
+        )
     return list(
-        RandomToken(u"{}-{}".format(voucher.number, n))
+        dummy_random_token(n)
         for n
         in range(count)
     )
@@ -291,17 +302,31 @@ class DummyRedeemer(object):
         :return: An already-fired ``Deferred`` that has a list of
           ``UnblindedToken`` instances wrapping meaningless values.
         """
+        def dummy_unblinded_token(random_token):
+            random_value = b64decode(random_token.token_value.encode("ascii"))
+            unblinded_value = random_value + b"x" * (96 - len(random_value))
+            return UnblindedToken(b64encode(unblinded_value).decode("ascii"))
         return succeed(
             list(
-                UnblindedToken(token.token_value)
+                dummy_unblinded_token(token)
                 for token
                 in random_tokens
             ),
         )
 
     def tokens_to_passes(self, message, unblinded_tokens):
+        def token_to_pass(token):
+            # Smear the unblinded token value across the two new values we
+            # need.
+            bs = b64decode(token.unblinded_token.encode("ascii"))
+            preimage = bs[:48] + b"x" * 16
+            signature = bs[48:] + b"y" * 16
+            return Pass(
+                b64encode(preimage).decode("ascii"),
+                b64encode(signature).decode("ascii"),
+            )
         return list(
-            Pass(token.text)
+            token_to_pass(token)
             for token
             in unblinded_tokens
         )
@@ -453,7 +478,7 @@ class RistrettoRedeemer(object):
         assert isinstance(unblinded_tokens, list)
         assert all(isinstance(element, UnblindedToken) for element in unblinded_tokens)
         unblinded_tokens = list(
-            privacypass.UnblindedToken.decode_base64(token.text.encode("ascii"))
+            privacypass.UnblindedToken.decode_base64(token.unblinded_token.encode("ascii"))
             for token
             in unblinded_tokens
         )
@@ -472,16 +497,15 @@ class RistrettoRedeemer(object):
             for token
             in unblinded_tokens
         )
-        marshaled_passes = list(
-            preimage.encode_base64() + b" " + signature.encode_base64()
+        passes = list(
+            Pass(
+                preimage.encode_base64().decode("ascii"),
+                signature.encode_base64().decode("ascii"),
+            )
             for (preimage, signature)
             in zip(clients_preimages, clients_signatures)
         )
-        return list(
-            Pass(p.decode("ascii"))
-            for p
-            in marshaled_passes
-        )
+        return passes
 
 
 @attr.s
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 52bd98e41bb1e93fce19849fbafc9908800c76c5..114715b903dde719c1bde945ed61cb17bf492f45 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -27,7 +27,9 @@ from json import (
 from datetime import (
     datetime,
 )
-
+from base64 import (
+    b64decode,
+)
 from zope.interface import (
     Interface,
     implementer,
@@ -47,6 +49,10 @@ from twisted.python.filepath import (
     FilePath,
 )
 
+from ._base64 import (
+    urlsafe_b64decode,
+)
+
 from .storage_common import (
     BYTES_PER_PASS,
     required_passes,
@@ -365,7 +371,7 @@ class VoucherStore(object):
             INSERT INTO [unblinded-tokens] VALUES (?)
             """,
             list(
-                (t.text,)
+                (t.unblinded_token,)
                 for t
                 in unblinded_tokens
             ),
@@ -606,6 +612,31 @@ class LeaseMaintenanceActivity(object):
 # x = store.get_latest_lease_maintenance_activity()
 # xs.started, xs.passes_required, xs.finished
 
+def is_base64_encoded(b64decode=b64decode):
+    def validate_is_base64_encoded(inst, attr, value):
+        try:
+            b64decode(value.encode("ascii"))
+        except (TypeError, Error):
+            raise TypeError(
+                "{name!r} must be base64 encoded unicode, (got {value!r})".format(
+                    name=attr.name,
+                    value=value,
+                ),
+            )
+    return validate_is_base64_encoded
+
+def has_length(expected):
+    def validate_has_length(inst, attr, value):
+        if len(value) != expected:
+            raise ValueError(
+                "{name!r} must have length {expected}, instead has length {actual}".format(
+                    name=attr.name,
+                    expected=expected,
+                    actual=len(value),
+                ),
+            )
+    return validate_has_length
+
 
 @attr.s(frozen=True)
 class UnblindedToken(object):
@@ -615,12 +646,18 @@ class UnblindedToken(object):
     and can be used to construct a privacy-preserving pass which can be
     exchanged for service.
 
-    :ivar unicode text: The base64 encoded serialized form of the unblinded
-        token.  This can be used to reconstruct a
+    :ivar unicode unblinded_token: The base64 encoded serialized form of the
+        unblinded token.  This can be used to reconstruct a
         ``privacypass.UnblindedToken`` using that class's ``decode_base64``
         method.
     """
-    text = attr.ib(validator=attr.validators.instance_of(unicode))
+    unblinded_token = attr.ib(
+        validator=attr.validators.and_(
+            attr.validators.instance_of(unicode),
+            is_base64_encoded(),
+            has_length(128),
+        ),
+    )
 
 
 @attr.s(frozen=True)
@@ -628,19 +665,47 @@ class Pass(object):
     """
     A ``Pass`` instance completely represents a single Zero-Knowledge Access Pass.
 
-    :ivar unicode text: The text value of the pass.  This can be sent to a
-        service provider one time to anonymously prove a prior voucher
+    :ivar unicode pass_text: The text value of the pass.  This can be sent to
+        a service provider one time to anonymously prove a prior voucher
         redemption.  If it is sent more than once the service provider may
         choose to reject it and the anonymity property is compromised.  Pass
         text should be kept secret.  If pass text is divulged to third-parties
         the anonymity property may be compromised.
     """
-    text = attr.ib(validator=attr.validators.instance_of(unicode))
+    preimage = attr.ib(
+        validator=attr.validators.and_(
+            attr.validators.instance_of(unicode),
+            is_base64_encoded(),
+            has_length(88),
+        ),
+    )
+
+    signature = attr.ib(
+        validator=attr.validators.and_(
+            attr.validators.instance_of(unicode),
+            is_base64_encoded(),
+            has_length(88),
+        ),
+    )
+
+    @property
+    def pass_text(self):
+        return u"{} {}".format(self.preimage, self.signature)
 
 
 @attr.s(frozen=True)
 class RandomToken(object):
-    token_value = attr.ib(validator=attr.validators.instance_of(unicode))
+    """
+    :ivar unicode token_value: The base64-encoded representation of the random
+        token.
+    """
+    token_value = attr.ib(
+        validator=attr.validators.and_(
+            attr.validators.instance_of(unicode),
+            is_base64_encoded(),
+            has_length(128),
+        ),
+    )
 
 
 @attr.s(frozen=True)
@@ -759,7 +824,13 @@ class Voucher(object):
         this voucher if it has been redeemed, ``None`` if it has not been
         redeemed.
     """
-    number = attr.ib()
+    number = attr.ib(
+        validator=attr.validators.and_(
+            attr.validators.instance_of(unicode),
+            is_base64_encoded(urlsafe_b64decode),
+            has_length(44),
+        ),
+    )
     created = attr.ib(
         default=None,
         validator=attr.validators.optional(attr.validators.instance_of(datetime)),
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 4ba150cd8589c160bf895fc4dc6277100f93ca8e..a4f4eadd80644a13a045015aa8fbf21d0f3cc043 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -17,6 +17,7 @@ Hypothesis strategies for property testing.
 """
 
 from base64 import (
+    b64encode,
     urlsafe_b64encode,
 )
 from datetime import (
@@ -77,6 +78,17 @@ from ..model import (
     Redeemed,
 )
 
+# Sizes informed by
+# https://github.com/brave-intl/challenge-bypass-ristretto/blob/2f98b057d7f353c12b2b12d0f5ae9ad115f1d0ba/src/oprf.rs#L18-L33
+
+# The length of a `TokenPreimage`, in bytes.
+_TOKEN_PREIMAGE_LENGTH = 64
+# The length of a `Token`, in bytes.
+_TOKEN_LENGTH = 96
+# The length of a `UnblindedToken`, in bytes.
+_UNBLINDED_TOKEN_LENGTH = 96
+# The length of a `VerificationSignature`, in bytes.
+_VERIFICATION_SIGNATURE_LENGTH = 64
 
 def _merge_dictionaries(dictionaries):
     result = {}
@@ -296,30 +308,76 @@ def voucher_objects():
     )
 
 
-def random_tokens():
+def byte_strings(label, length, entropy):
     """
-    Build random tokens as unicode strings.
+    Build byte strings of the given length with at most the given amount of
+    entropy.
+
+    These are cheaper for Hypothesis to construct than byte strings where
+    potentially the entire length is random.
     """
+    if len(label) + entropy > length:
+        raise ValueError("Entropy and label don't fit into {} bytes".format(
+            length,
+        ))
     return binary(
-        min_size=32,
-        max_size=32,
+        min_size=entropy,
+        max_size=entropy,
     ).map(
-        urlsafe_b64encode,
+        lambda bs: label + b"x" * (length - entropy - len(label)) + bs,
+    )
+
+
+def random_tokens():
+    """
+    Build ``RandomToken`` instances.
+    """
+    return byte_strings(
+        label=b"random-tokens",
+        length=_TOKEN_LENGTH,
+        entropy=4,
+    ).map(
+        b64encode,
     ).map(
         lambda token: RandomToken(token.decode("ascii")),
     )
 
 
+def token_preimages():
+    """
+    Build ``unicode`` strings representing base64-encoded token preimages.
+    """
+    return byte_strings(
+        label=b"token-preimage",
+        length=_TOKEN_PREIMAGE_LENGTH,
+        entropy=4,
+    ).map(
+        lambda bs: b64encode(bs).decode("ascii"),
+    )
+
+
+def verification_signatures():
+    """
+    Build ``unicode`` strings representing base64-encoded verification
+    signatures.
+    """
+    return byte_strings(
+        label=b"verification-signature",
+        length=_VERIFICATION_SIGNATURE_LENGTH,
+        entropy=4,
+    ).map(
+        lambda bs: b64encode(bs).decode("ascii"),
+    )
+
+
 def zkaps():
     """
     Build random ZKAPs as ``Pass` instances.
     """
     return builds(
-        lambda preimage, signature: Pass(u"{} {}".format(preimage, signature)),
-        # Sizes informed by
-        # https://github.com/brave-intl/challenge-bypass-ristretto/blob/2f98b057d7f353c12b2b12d0f5ae9ad115f1d0ba/src/oprf.rs#L18-L33
-        preimage=binary(min_size=64, max_size=64).map(urlsafe_b64encode),
-        signature=binary(min_size=64, max_size=64).map(urlsafe_b64encode),
+        Pass,
+        preimage=token_preimages(),
+        signature=verification_signatures(),
     )
 
 
@@ -329,11 +387,12 @@ def unblinded_tokens():
     base64 encode data.  You cannot use these in the PrivacyPass cryptographic
     protocol but you can put them into the database and take them out again.
     """
-    return binary(
-        min_size=32,
-        max_size=32,
+    return byte_strings(
+        label=b"unblinded-tokens",
+        length=_UNBLINDED_TOKEN_LENGTH,
+        entropy=4,
     ).map(
-        urlsafe_b64encode,
+        b64encode,
     ).map(
         lambda zkap: UnblindedToken(zkap.decode("ascii")),
     )
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 5485b16bf2bcd0f0790ec7b2f123901c7fef88b2..dec0176991e75bc905b725fd6a08ba165c4b1d3c 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -469,7 +469,7 @@ def ristretto_verify(signing_key, message, marshaled_passes):
             VerificationSignature.decode_base64(s.encode("ascii")),
         )
     servers_passes = list(
-        decode(marshaled_pass.text)
+        decode(marshaled_pass.pass_text)
         for marshaled_pass
         in marshaled_passes
     )
diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py
index d595e7c104c3ac0edf79d374131ed36a505f8711..faa4f8f76517062fbc1cea13279f830f73cd373c 100644
--- a/src/_zkapauthorizer/tests/test_storage_protocol.py
+++ b/src/_zkapauthorizer/tests/test_storage_protocol.py
@@ -211,7 +211,7 @@ class ShareTests(TestCase):
         def get_passes(message, count):
             self.spent_passes += count
             return list(
-                Pass(pass_.decode("ascii"))
+                Pass(*pass_.split(u" "))
                 for pass_
                 in make_passes(
                     self.signing_key,
diff --git a/src/_zkapauthorizer/tests/test_storage_server.py b/src/_zkapauthorizer/tests/test_storage_server.py
index 8d28db831e18aa3dff385db6e12fe2d7208a68b2..4cef248a8ae6b09f38d7968c220903fa45480b42 100644
--- a/src/_zkapauthorizer/tests/test_storage_server.py
+++ b/src/_zkapauthorizer/tests/test_storage_server.py
@@ -127,7 +127,11 @@ class PassValidationTests(TestCase):
             message,
             list(RandomToken.create() for i in range(valid_count)),
         )
-        all_passes = valid_passes + list(pass_.text.encode("ascii") for pass_ in invalid_passes)
+        all_passes = valid_passes + list(
+            pass_.pass_text.encode("ascii")
+            for pass_
+            in invalid_passes
+        )
         shuffle(all_passes)
 
         self.assertThat(