Normalize a one-time code inside its digest, not in the handler
A one-time code leaves the server as six characters and comes back as whatever the person retyped: the hyphen a message template inserted, a trailing space from a paste, lowercase letters from a phone keyboard. The stored side is a digest of the clean value, so every one of those differences is a mismatch unless something folds the input first. Where that folding lives decides whether the two sides of the comparison can ever disagree — and whether the strength the code advertises is the strength it has.
We build verification paths often: onboarding and KYC flows, payment confirmation, operator tooling behind a compliance desk. The one-time-code part is small, and it is almost always the part that got written twice — once in the endpoint that issues the code, once in the endpoint that checks it. In otpguard, the library we keep this logic in (https://github.com/shipmindlabs/otpguard), the folding lives in one method, both directions call it, and the configurations that would make it lossy cannot be constructed at all.
Two call sites, one rule#
Normalization has exactly one safe home: inside the function that produces the digest. Put it in the request handler and you have two handlers to keep in agreement — and the failure is silent, because a code that was stored folded and submitted unfolded simply reads as wrong, which is indistinguishable from a wrong code.
So the rule is a method on a policy object, and the policy travels with the code:
@dataclass(frozen=True)
class CodePolicy:
"""Shape of a one-time code and how user input is normalized before matching."""
length: int = 6
alphabet: str = DIGITS
case_sensitive: bool = False
separators: str = DEFAULT_SEPARATORS
def normalize(self, raw: str) -> str:
"""Drop separators and, for case-insensitive policies, fold the case."""
stripped = "".join(ch for ch in raw if ch not in self.separators)
return stripped if self.case_sensitive else stripped.upper()DEFAULT_SEPARATORS is " -\t\r\n" — the space, the hyphen and the whitespace a copy-paste drags along. Nothing else in the library strips anything; normalize is called once, from the hashing function:
def hash_code(
code: str,
policy: CodePolicy = DEFAULT_POLICY,
*,
pepper: bytes | str = b"",
salt: bytes | None = None,
) -> HashedCode:
if salt is None:
salt = secrets.token_bytes(SALT_BYTES)
elif not salt:
raise ValueError("salt must not be empty")
key = salt + _as_bytes(pepper)
digest = hmac.new(key, policy.normalize(code).encode("utf-8"), sha256).digest()
return HashedCode(algorithm=ALGORITHM, salt=salt, digest=digest)
def verify_code(
candidate: str,
hashed: HashedCode | str,
policy: CodePolicy = DEFAULT_POLICY,
*,
pepper: bytes | str = b"",
) -> bool:
if isinstance(hashed, str):
hashed = HashedCode.decode(hashed)
if hashed.algorithm != ALGORITHM:
return False
computed = hash_code(candidate, policy, pepper=pepper, salt=hashed.salt)
return hmac.compare_digest(computed.digest, hashed.digest)The detail worth noticing is that verify_code does not normalize. It cannot drift from the storage path because it has no folding of its own — it calls hash_code with the stored salt and the caller's policy. One implementation, reached from both ends. The remaining way to get a mismatch is to hand the two calls different policies, which is why the flow object holds a single policy and passes it down to generation, hashing and verification, and why the test-mode comparison takes the same policy for its own normalize before a constant-time compare. The policy is the contract; nothing else is allowed an opinion about whitespace.
From the test suite, the behaviour that falls out of this:
def test_verification_ignores_separators_and_case():
policy = CodePolicy(length=6, alphabet=UPPERCASE_UNAMBIGUOUS)
hashed = hash_code("AB2CD3", policy)
assert verify_code("ab2-cd3", hashed, policy)
assert verify_code(" AB2 CD3 ", hashed, policy)A policy that folds has to refuse lossy alphabets#
Folding is only free when it cannot remove information. Two configurations remove information, and a frozen dataclass is a good place to refuse both, because a policy that validates on construction is a policy that cannot be wrong later:
def __post_init__(self) -> None:
if self.length < 4:
raise ValueError("length must be at least 4")
if len(self.alphabet) < 2:
raise ValueError("alphabet must contain at least two characters")
comparable = self.alphabet if self.case_sensitive else self.alphabet.upper()
if len(set(comparable)) != len(comparable):
raise ValueError("alphabet must not contain duplicate characters")
if set(self.alphabet) & set(self.separators):
raise ValueError("separators must not overlap the alphabet")The overlap rule is the interesting one. Suppose an alphabet that includes the hyphen while the hyphen is still a separator. generate_code draws from the alphabet with secrets.choice, so a drawn code may legitimately contain a hyphen — and normalize will strip it before anything is hashed. The code that was delivered is then not the code that is matched: part of it was thrown away on the way into the digest. Worse, two different draws collapse onto the same stored value, so a code can verify against another code's digest. Nothing raises; the system just has fewer codes than it thinks.
The duplicate rule is the same failure in a different coat. Under a case-insensitive policy, a and A are one symbol after folding, so an alphabet containing both counts a character it does not actually have. This matters because the policy publishes a strength figure:
@property
def entropy_bits(self) -> float:
return self.length * math.log2(len(self.alphabet))entropy_bits is computed from len(self.alphabet), and the two validation rules are what make that length honest. Six digits is about 19.93 bits, and that number is only true if each of the ten characters survives normalization as itself. Refusing lossy alphabets at construction is how the property stays a measurement rather than an estimate.
| Policy | Refused because |
|---|---|
CodePolicy(length=3) |
below the floor on code length |
CodePolicy(alphabet="A") |
a one-symbol alphabet draws one code |
CodePolicy(alphabet="AAB") |
duplicate character, counted twice |
CodePolicy(alphabet="aA") |
one symbol after case folding |
CodePolicy(alphabet="AB-CD") |
the hyphen is also a separator |
Case sensitivity is a policy choice, not a global one, and mixed case is available once the folding is turned off:
def test_case_sensitive_policy_rejects_the_wrong_case():
policy = CodePolicy(length=6, alphabet="abcdefABCDEF", case_sensitive=True)
hashed = hash_code("aBcDeF", policy)
assert verify_code("aBcDeF", hashed, policy)
assert not verify_code("abcdef", hashed, policy)The shipped non-digit alphabet is built to sit comfortably under the default folding. UPPERCASE_UNAMBIGUOUS is "ABCDEFGHJKLMNPQRTUVWXYZ2346789" — uppercase only, with the visually ambiguous 0/O, 1/I and 5/S pairs removed. The readability decision and the normalization decision are the same decision: an alphabet with no lowercase members is unchanged by case folding, and an alphabet with no look-alikes is one where a transcription error is a wrong code rather than a coin flip.
What the digest holds, and what it never holds#
Folding fixes the match; it does nothing for what a leaked table is worth. A six-digit code is a space of one million candidates, which is to say a stored digest is only as private as the key that produced it. Hence the shape of the record:
- a fresh salt of
SALT_BYTES(16) per digest unless one is supplied, so the same code never yields the same digest twice, and an empty salt is refused outright; - the HMAC key is
salt + pepper, with the pepper supplied by the application and never written into the record; - the encoded form is
ALGORITHM, salt and digest joined with$as unpadded urlsafe base64, andHashedCode.decoderaisesValueErroron any other shape; verify_codereturnsFalsebefore computing anything when the stored algorithm is notALGORITHM, so an unrecognised record fails closed instead of being compared under today's rules;- the final comparison is
hmac.compare_digest.
The pepper is the part that is easy to leave out and expensive to add later. The store holds salt and digest; brute-forcing a million candidates against a known salt is not a project. Keyed with a secret the table never contains, the same dump is inert:
def test_pepper_is_required_to_verify():
hashed = hash_code("123456", pepper=b"server-secret")
assert verify_code("123456", hashed, pepper=b"server-secret")
assert not verify_code("123456", hashed)
assert not verify_code("123456", hashed, pepper=b"other-secret")What it costs to run#
The policy becomes a deployment-wide constant. Change the alphabet, the separators or the case sensitivity — or rotate the pepper — and every pending digest stops verifying, because the folding or the key on the storage side no longer matches the one on the verification side. That is a real cost and a small one, because the blast radius is bounded by the code's time to live: the only people affected are those holding a code in flight at the moment of the change, and they ask for another. Short-lived records are what make an otherwise breaking change a non-event, and it is worth knowing that before the first rotation rather than during it.
The other cost is that the application no longer decides how forgiving the comparison is, per call. If a stricter match is wanted, it is expressed as a policy — case-sensitive, or an empty separator string — not as an exception in one handler. That is the trade we want: the behaviour is in a value that can be read, asserted on, and passed to both sides, instead of in the control flow of whichever endpoint happened to be written first.
The rule generalises past one-time codes. A comparison that needs its inputs cleaned should own the cleaning, and whatever owns the cleaning should refuse every configuration under which the cleaning loses information. The first half keeps two code paths in agreement; the second half keeps the system from quietly having a smaller secret than it reports.