When you build a payment rail around a token — a short, redeemable claim on value that a person can receive by SMS, read over the phone, or type into a USSD menu — the token is the money. Get its design wrong and you have either a fraud surface or an operations nightmare. This is the story of how we redesigned PayWithToken's token so it is unpredictable, leaks nothing about the sender, survives a reprint, and can be validated on a feature phone with no network — and the small number of ideas that made all of that fit into 18 digits.
The trap: "obvious" tokens
The first version of almost every token scheme looks reasonable and is quietly dangerous. A common pattern is to compose the token from things you already have — a phone number and a timestamp, maybe a sequence counter:
token = last9(phone) + HHMMSS + seq
It is easy to generate, easy to look up, and completely broken:
It is predictable. If I know (or guess) your phone number and roughly when a token was issued, I can enumerate a very small space of candidates. Bearer instruments must not be guessable.
It leaks PII. The token now contains a phone number. Anyone who sees a token — printed on a receipt, forwarded in a chat — learns something about the parties.
It collides. Two tokens minted in the same second for the same user need ugly tie-breakers.
It can't be checked offline. A mistyped digit sails straight through to a failed redemption at the counter.
We wanted the opposite of all four properties.
The requirements
Before touching code, we wrote down what a token had to be:
Numeric and short. It has to be entered on a phone keypad and read aloud, so: digits only, 18 of them.
Unpredictable. Knowing any number of past tokens must not help you forge the next one.
PII-free. The token must reveal nothing about the sender, recipient, amount, or time.
Offline-verifiable for typos. A cashier or USSD flow should catch an obvious mistype without a round trip.
Reprint-safe. If a customer loses the SMS, we must be able to re-issue the exact same token — without ever having stored the token itself.
Hash-only at rest. The database must never hold a plaintext token. If the table leaks, the tokens are useless.
Requirements 5 and 6 look contradictory — reproduce the token, but don't store it — and resolving that tension is the interesting part.
The design
The token is the output of a keyed hash, mapped to digits, with a Luhn check digit on the end.
- Deterministic inputs, secret key We never hash the token; we hash the facts that define it. Those are things we already persist for the transaction anyway — a monotonic issue reference, the wallet/transaction id, a per-token counter — assembled in a fixed, canonical order:
message = canonical(issue_ref, txn_id, counter) # deterministic, no secrets
mac = HMAC_SHA256(server_key, message) # 32 bytes, unpredictable
server_key is a high-entropy secret that lives outside the web root, in environment configuration / a secrets manager, and is rotated on a schedule. The unpredictability of the token comes entirely from this key: without it, HMAC output is computationally infeasible to reproduce or reverse.
- Bytes to digits We turn the 32-byte MAC into a big integer and take it modulo 10^17 to get 17 digits, then compute a Luhn check digit to make 18:
n17 = int_from_bytes(mac) % 10**17 # 17 digits
check = luhn_check_digit(n17) # 1 digit
token = zero_pad(n17, 17) + str(check) # 18 digits
Luhn — integrity, not security
The final digit is the same checksum the card industry uses. It is not a security control — it stops honest typos, not attackers. One transposed or wrong digit fails the Luhn test instantly, on-device, with no network. That single digit removes a whole class of "valid-looking but wrong" tokens from ever reaching the backend.Reprint-safety without storing the token
Here is the resolution to the apparent contradiction. Because generation is a pure function of stored inputs and the key, we can always recompute the exact token on demand:
reissue(issue_ref, txn_id, counter) -> same 18 digits, every time
So to reprint or resend, we don't fetch a stored token — we regenerate it from the transaction record. Meanwhile, what we store for validation is only the token's hash:
store: token_hash = SHA256(token) # never the token itself
verify: SHA256(entered_token) == token_hash ? # constant-time compare
A database dump therefore contains no spendable value. Redemption is a hash comparison; reprint is a recomputation; the plaintext exists only in transit to the recipient.
The partner-format contract
There is one more real-world wrinkle. We also issue an 11-digit token variant for white-label partners whose systems were built expecting exactly 11 digits. When we rolled out the keyed scheme, we could not change that length without breaking every partner integration — the token's shape was effectively an API contract.
The design handles this by construction: the only length-dependent step is the modulus. Take the MAC modulo 10^10, append the Luhn digit, and you have a secure 11-digit token with identical properties. Same engine, same guarantees, different envelope — and no partner had to change a line of code.
n10 = int_from_bytes(mac) % 10**10
token = zero_pad(n10, 10) + luhn_check_digit(n10) # 11 digits, still keyed + checked
What the token does not do (and how we cover it)
A well-designed token value is necessary but not sufficient. Because a token is a bearer instrument, the surrounding system still has to:
Make redemption one-time and atomic, so a token can't be spent twice under a race.
Support suspension and expiry, so a compromised or disputed token can be killed instantly, everywhere.
Rate-limit validation, so nobody can brute-force the (already vast) space by hammering the redeem endpoint.
Protect and rotate the key, because the key is the whole security model.
Get those right around a keyed, checksummed, hash-stored token and you have something you can safely send by SMS to a stranger.
Takeaways
Never compose a token from user data. Predictability and PII leakage are baked in from the first line.
Let a keyed hash supply the entropy; the secret key is the security boundary, not the format.
Store the hash, regenerate from inputs. That single decision gives you reprint-safety and a database that holds no spendable value.
Add a Luhn digit for humans, not attackers — it pays for itself the first time someone reads a token aloud.
Treat length as a contract. A modulus is all that separates an 18-digit consumer token from an 11-digit partner token with the same guarantees.
Designing money to travel as eighteen typed digits forces a satisfying discipline: every property you want has to be earned explicitly. The result is a token that is unpredictable, private, human-checkable, reproducible, and worthless at rest — which is exactly what you want the thing that is the money to be.
Top comments (0)