Engineering · 2026-08-13
Phone and Email Hashing: From Plain SHA-256 to HMAC-SHA-256
By J. W. Bouckaert
Hashed identifiers and privacy claims
Whether the statement "We store only irreversible hashes of personal identifiers" is true depends on what is being hashed and how. For a random 256-bit API key, an unkeyed SHA-256 digest cannot be reversed by trying every input. For a phone number, it can be reversed by enumerating every possible number.
This post is the engineering story of making that statement true at PasskeyBridge: moving every low-entropy personal identifier we store, phone numbers, email addresses, CAEP subjects, IP addresses, and behavioral fingerprints, from unkeyed SHA-256 to keyed HMAC-SHA-256 under a server-held pepper, while keeping the deterministic correlation the platform depends on. The post also lists the digests that could not be migrated.
The arithmetic of enumeration
A cryptographic hash is one-way only when the attacker cannot afford to try every input. Identifier keyspaces are small:
Input class Keyspace (order of magnitude)
────────────────────────────────────────────────────────
256-bit API key 2^256 (beyond enumeration)
IPv4 address 2^32 ≈ 4.3 × 10^9
NANP phone number ~10^10 valid combinations
Email addresses not enumerable in general, but
attackers hold billion-row breach
corpora: a dictionary lookup
────────────────────────────────────────────────────────
A commodity machine computes SHA-256 at hundreds of megahashes per second per core; GPU rigs add three orders of magnitude. Against a 2^32 IPv4 space, an unkeyed digest is reversible on a laptop in minutes. Against the phone-number space, an attacker builds a rainbow table once and then reverses any phone digest by lookup. For email addresses, attackers use publicly circulating breach corpora as the dictionary.
The arithmetic shows that an unkeyed hash of a low-entropy identifier is a pseudonym: anyone who can enumerate the input space can reverse it. Under GDPR's definition of pseudonymisation, data that can be re-attributed with reasonably available means is still personal data, and an unkeyed hash of a phone number is that kind of data.
Salts and the correlation requirement
Password storage adds a salt. Neither kind of salt meets the design constraint here, for the two reasons below.
A random per-record salt breaks correlation. Password hashing wants every stored digest to be unique even for identical inputs; that is what the salt is for. An identity platform needs the same phone number arriving in a carrier webhook today and an authentication request next week to produce the same digest, so the two can be matched. The platform depends on deterministic equality, so a random salt breaks matching.
A shared or derivable salt leaves enumeration open. A single global salt, or a rotating date-based one, preserves determinism, and anyone who inspects the system learns it. An attacker who knows the salt scheme rebuilds the enumeration table with the salt included. Our behavioral fingerprints use a daily salt to unlink days from each other, and the design documentation describes this salt as a linkability control with no effect on enumeration.
The system needs a deterministic function whose output cannot be computed without a secret key.
Keyed and deterministic: HMAC under a pepper
The construction that shipped is HMAC-SHA-256 with a single server-held secret, the pepper, as the key. Properties, in the order they mattered:
- Equality is kept and precomputation is blocked. The digest is deterministic under the pepper, so correlation works exactly as before. Without the pepper, no rainbow table can be built and no candidate input can be tested. A leaked digest column discloses equality relationships: same identifier, same digest.
- Drop-in shape. Output is 64 lowercase hex characters, the same shape as a SHA-256 hex digest, so every existing column, index, and format validator kept working unchanged. The migration work was in the write paths.
- Identifier classes are domain-separated. The identifier's class (phone, email, subject, IP, fingerprint) and a version tag are bound into the HMAC message alongside the normalized value. The same raw string used as two different identifier types yields unrelated digests, so a cross-type join does not match by accident.
- Normalization happens before hashing, per class. Phone numbers are reduced toward E.164. IP addresses are lowercased and stripped of the bracket-and-port decorations proxies append, so
[2001:DB8::1]:443and2001:db8::1correlate as one host. Emails and subjects are trimmed and lowercased. Two formats of the same identifier produce the same digest only when normalization maps them to the same string.
One detail: the helper that hashes IP addresses returns null for placeholder values (absent addresses, "unknown", and per-request anonymous buckets). A digest of a placeholder would be shaped like an identifier for a value that names no host, and downstream correlation, including insider-threat "new IP" detection, would count each one as a new host.
Version stamps
Every stored digest now carries a hash version: version 1 is a legacy unkeyed SHA-256, or a digest a tenant computed client-side and sent to us pre-hashed; version 2 is a server-side keyed HMAC. The stamp makes every row self-describing. Version-1 digests are excluded from every privacy claim because they are enumerable.
Version-1 rows cannot be upgraded in place. Re-keying a digest requires the plaintext, and the plaintext was never retained, by design. The zero-PII property also means the platform cannot re-key its own history. Legacy rows age out on retention schedules instead. The one exception was a migration path that briefly held plaintext at migration time anyway, and could therefore re-key the rows that passed through it.
If you are planning the same migration: accept this before you start, budget for the aging-out window, and write the version stamp from day one so each row records which scheme produced it.
Fail closed
The hashing helper throws when its pepper is missing or too short, and the write fails.
If the helper logged a warning and fell back to an unkeyed digest, rows would carry a "keyed" version stamp over unkeyed bytes, and every downstream claim built on those stamps (documentation, audit evidence, breach-notification analysis) would be wrong. Because the helper throws, a missing pepper makes every keyed write fail visibly, and an operator-run self-test checks that the live pepper keys a real stored row.
Pepper rotation with an overlap window
Rotation is how a compromised pepper is replaced. Rotating it changes every future digest, which would orphan every stored one. The shipped protocol:
- The outgoing pepper moves to a previous-pepper slot; the new pepper takes over all writes immediately.
- Equality-matching reads compute candidate digests under both peppers for the overlap window, so rows written under the old pepper still match.
- When every equality-matched row from the old era has aged out or been rewritten, the previous pepper is cleared and the window closes.
Steady state costs one HMAC per lookup, identical to before; only the rotation window pays for two. One implementation detail: the imported HMAC key is cached per pepper value, so a rotation takes effect on the next call in an already-warm serverless isolate.
Deliberately unkeyed
Two classes of digest stay on plain SHA-256 because keying them would break them:
- High-entropy secrets. API keys, agent and bearer tokens, DPoP access tokens. Their keyspace is 2^256-shaped, so unkeyed digests are not enumerable. They are looked up by digest, so re-keying would invalidate every stored hash.
- Content digests. Payload hashes, provenance hashes, canonical JCS digests. These exist so an independent party can recompute and verify them. A keyed digest is verifiable only by the keyholder, so these stay unkeyed.
A plain SHA-256 over values that are already keyed digests is also safe. The SIM-signal and credential cross-reference post uses one: the carrier-signal identifier and the credential subject identifier are each keyed with HMAC-SHA-256 first, and the binding hash is computed over those two digests.
Credential-subject identifiers were on this list when the post was first published, on the reasoning that DID-class strings sit outside the phone and email enumeration class. That reasoning was wrong: a DID or an issuer's subject identifier is frequently an email, a phone number or a username with a prefix, and is as enumerable as what it names. Since 2026-09-03 they are keyed like any other identifier, and reads match the older unkeyed digests alongside the keyed ones, so historical correlation is kept and new subjects are keyed. The SD-JWT selective disclosure post describes the issuance side: the issuer signs the claims it submits, and PasskeyBridge stores only a keyed digest of the subject identifier.
One audit finding: a column whose name ended in _hash was populated from a plaintext column and held addresses. Our audits check the values that flow into each column.
Client-side hashing
An SDK cannot hold the pepper. A server secret shipped in a browser bundle or a mobile binary can be extracted by anyone who has the bundle or binary.
This shapes the ingest API. Our ingest path still accepts a client-computed phone hash for backward compatibility. Until 2026-09-18 those digests were stored exactly as sent, stamped version 1 and excluded from privacy claims; since then the server keys the received digest before storing it, so the stored value is no longer the enumerable one the client computed. Either way, because the client's digest can never equal the server's keyed digest of the raw number, client-hashed values correlate only with other client-hashed values. Tenants who want keyed digests send the raw identifier over TLS and let the server key it. Hashing on the client gives an unkeyed, enumerable digest that also fails to correlate with server-keyed digests. The keyed transform runs on the server, where the pepper is held.
A single global pepper
The pepper is global: one secret for the platform. That was a deliberate choice. Per-tenant peppers would make the same identifier produce different digests in different tenants. That would isolate tenants, and it would also prevent cross-tenant threat correlation. The platform's anonymized threat-sharing depends on recognizing that the same (hashed) identifier is being attacked across multiple tenants without knowing what the identifier is.
The cost of that choice is concentration: one secret now underwrites the enumeration resistance of every identifier digest on the platform. Pepper custody is therefore a first-order control: it is held in the platform secret store and is not stored in the database, so a database compromise does not expose it; a missing pepper stops keyed writes; its rotation is a written procedure whose code paths are tested. The custody side, including what a rotation depends on and what it has not yet been through, is covered in pepper custody.
Boundary of the control
The control has these limits. For contract language that makes a vendor document each digest's input keyspace and where its key is stored, see six contract clauses that test a zero-PII claim.
- Equality still leaks structure. A leaked digest column reveals which rows share an identifier: graph shape, frequency, co-occurrence. Our threat-sharing design uses that equality; assess it against your own threat model.
- The property is conditional on the pepper. If the pepper itself is compromised, keyed digests become as enumerable as the unkeyed digests the migration replaced. The claim is "irreversible without the pepper," and it holds while the pepper stays secret.
- Version-1 history. Legacy digests, and client-supplied digests stored before 2026-09-18, remain enumerable until they age out. We stamp them and exclude them from claims. Re-keying them would need plaintext that was never kept.
- This is pseudonymization. A keyed digest has no decryption path for us or anyone else. The original identifier cannot be recovered from the digest.
If you are making a zero-PII claim anywhere in your own product, the one-sentence audit is: for every stored digest, what is the keyspace of its input, and where does the key live? If the input keyspace is small and there is no key, the digest can be reversed by enumeration and the claim does not hold. Our claim rests on a version stamp, a pepper, a fail-closed helper, and an aging-out window. The broader architecture this control lives inside is documented in our zero-PII architecture post and the CISO guide to zero-PII verification; the compliance surfaces it feeds, processing phone-derived signals without storing numbers and signal residency under Schrems III, are covered separately. The mechanics above are in production today; the docs describe the tenant-facing surface, including what to send raw and how pre-hashed values are handled.