Shipmind Labs

Why a six-digit code is safe: entropy, attempts and the pepper

· 7 min read

A six-digit numeric code carries 19.93 bits of entropy, a number no password policy would accept. It is still the correct primitive for passwordless login and step-up confirmation, but only because two other mechanisms carry most of the guarantee: a bounded attempt budget, and a stored digest that cannot be ground down offline. When those two live in whichever request handler happened to need them, the safety of the code stops being a property of the system and becomes a property of the last person who edited the handler.

We extracted this reasoning into a small library, otpguard (https://github.com/shipmindlabs/otpguard), after building the same flow more than once across payment confirmation, KYC re-verification and account recovery. It is early development and the public API is not stable yet, but the shape of the problem is settled, and the shape is the interesting part.

The arithmetic nobody writes down#

The security of a one-time code is not the entropy of the code. It is the entropy divided by the number of guesses an attacker is allowed against it. The library makes the first half of that fraction explicit:

python
from otpguard import DIGITS, UPPERCASE_UNAMBIGUOUS, CodePolicy

CodePolicy(length=6, alphabet=DIGITS).entropy_bits                  # 19.93
CodePolicy(length=8, alphabet=UPPERCASE_UNAMBIGUOUS).entropy_bits   # 39.2

Six digits is one million possibilities. Five allowed attempts against a single issued code is a five-in-a-million chance of a hit. That is a perfectly reasonable number for confirming a transfer, and it holds only as long as the five is real.

Notice which lever is cheap. Adding two characters to the code is expensive: users retype it from a message, read it aloud on a support call, mistype it more often, and ask for another one. Lowering the attempt ceiling from twenty to five costs nothing and moves the same fraction by a factor of four. The attempt ceiling is the lever that actually protects the flow, and it is the one that is usually implemented as an integer comparison somewhere in a view function.

The budget is state, which is why it belongs in the machine#

An attempt ceiling is not a constant. It is a counter, a lockout deadline and a resend cooldown, all of which are written and read concurrently, and all of which have to survive being reached from more than one entry point. A code confirmed in a mobile app, on the web and by a support operator re-triggering delivery is the same code with the same budget.

The resend cooldown is the part that gets misread most often. It looks like politeness about message cost. It is not — it is what stops the attempt budget from being refilled on demand. If the attempt counter is scoped to the pending code rather than to the identity and the purpose behind it, then requesting a fresh code resets the counter, and an attacker with an unbounded resend button has an unbounded number of attempts against a twenty-bit space. The cooldown and the counter are two views of one budget, which is exactly why they cannot be two separate features owned by two separate call sites.

In otpguard the cooldown is authoritative about the rule and deliberately holds no state itself. The caller stores the moment of the last delivery next to the pending code, in their own table, inside their own transaction, and passes it back:

python
from datetime import datetime, timedelta, timezone
from otpguard import ResendCooldown, ResendTooSoon

cooldown = ResendCooldown(timedelta(seconds=60))

try:
    cooldown.check(last_sent_at)        # None on the first request
except ResendTooSoon as exc:
    exc.retry_after                     # timedelta(seconds=43)
    exc.retry_after_seconds             # 43.0, ready for a Retry-After header
else:
    send(generate_code())
    last_sent_at = datetime.now(timezone.utc)

The refusal carries the remaining interval, so the HTTP layer does not recompute it and cannot disagree with the library about when the next code is allowed. retry_after() returns the same remaining time without raising, allows() answers with a bool, and next_allowed_at() gives the absolute moment — three shapes of one decision, so no caller has to derive it from a timestamp difference and get the sign wrong.

This is also the reason the library does not own a table. The decisions are pure; the row belongs to the application. That keeps the transition committable inside whatever transaction the caller already has open, and it means the invariants can be tested without a database, a broker or a clock.

Delivery is the one thing that genuinely varies. SMS, email and messenger fan-out sit behind the same request-verify flow, and we have built multi-channel notification services often enough to know how much variety hides there. None of that variety is allowed to reach the budget.

An offline attacker has no attempt budget at all#

Everything above assumes the attacker is online, spending attempts against your endpoint. If the table leaks, that assumption is gone, and a million HMAC evaluations is a rounding error of CPU time. Twenty bits protects nothing offline.

So the stored form has to be a digest, salted, and — if you want the online requirement back — bound to a secret that does not live in the database:

python
from otpguard import generate_code, hash_code, verify_code

code = generate_code()                  # '482913'
stored = hash_code(code, pepper=SERVER_SECRET).encode()

verify_code(candidate, stored, pepper=SERVER_SECRET)

The clear-text code exists only long enough to be delivered. A fresh salt is drawn on every hash, so the same code never produces the same digest twice and the table shows no collisions to sort by. Comparison goes through hmac.compare_digest, so verification does not leak position information through timing. The stored string carries its algorithm — hmac-sha256$<salt>$<digest> — and a digest tagged with anything else never verifies rather than falling back to something older.

The pepper is what restores the arithmetic after a dump: without the secret, the offline attacker is back to being an online one.

The code you send and the code the user types are different strings#

Users paste codes with spaces, hyphens and stray case. Support operators read them over the phone. CodePolicy owns that normalization, and both hashing and verification run through the same normalize, so the two sides cannot drift:

python
policy = CodePolicy(length=6, alphabet=UPPERCASE_UNAMBIGUOUS)
hashed = hash_code("AB2CD3", policy)

verify_code("ab2-cd3", hashed, policy)     # True
verify_code(" AB2 CD3 ", hashed, policy)   # True

The bundled UPPERCASE_UNAMBIGUOUS alphabet drops the characters that are easy to confuse when a code is read aloud — 0 and O, 1 and I, 5 and S. This looks like a usability detail, and it is one, but it feeds straight back into the budget: every code a user cannot retype correctly is a resend, and resends are the pressure that makes teams raise the ceiling.

Case folding also has a cost, and the policy refuses to let you hide it. An alphabet containing both a and A is rejected for a case-insensitive policy, because the two characters are the same character once input is folded, and entropy_bits would otherwise report a number the system does not actually deliver. Codes shorter than four characters and alphabets that overlap the separator set are rejected for the same reason: a configuration whose entropy claim is a lie should fail at construction, not in an incident review.

What it costs to run#

One row per pending code: the encoded digest, the purpose it was issued for, the moment of last delivery, an attempt count and a lockout deadline. One write per transition, all of them in the caller's own transaction. No extra infrastructure — no cache tier, no broker, nothing that can be up while the database is down and disagree with it afterwards.

The pepper is the one operational commitment. A peppered digest verifies only under the same pepper, so rotating it invalidates every code minted under the old one. With a code lifetime measured in minutes that is a non-event, provided you know it before you rotate rather than after.

None of this is difficult. It is simply arithmetic that has to be owned somewhere, and a request handler is not somewhere. The code is the smallest part of a one-time code; the budget around it is the product.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com