emailverifier.dev
Posts

Should You Hash Email Addresses? A Secure Storage Pattern

| 8 min read | Usama Ejaz
An email address splits into a locked encrypted record and a separate keyed lookup fingerprint before both are stored in a database grid.

If your application must send email, a plain hash cannot replace the address. I would store two deliberately different representations: randomized authenticated ciphertext that an authorized service can decrypt, and a keyed HMAC lookup value for exact-match login and uniqueness checks.

The encryption protects the address in a database-only leak. The lookup value lets the application find a record without decrypting every row. Neither makes the address anonymous, and neither replaces access control, retention limits, or secure key management.

This is the storage contract I would build around:

create table users (
  id uuid primary key,
  tenant_id uuid not null,
  email_box bytea not null,
  email_crypto_version smallint not null,
  email_verified_at timestamptz,
  email_verification_status text,
  email_verification_action text,
  email_verification_signals text[],
  email_checked_at timestamptz
);

create table user_email_lookup (
  user_id uuid not null references users(id) on delete cascade,
  tenant_id uuid not null,
  key_version smallint not null,
  digest bytea not null,
  primary key (user_id, key_version),
  unique (tenant_id, key_version, digest)
);

email_box is a versioned envelope containing the original address, the delivery address, and the canonical comparison value. digest is an HMAC over the canonical value, scoped to the tenant when accounts are unique only within a tenant. The separate lookup table allows old and new HMAC versions to coexist during rotation.

Why not store SHA-256 of the email?

Email addresses are guessable identifiers. An attacker with a database of plain SHA-256 values can hash likely addresses and compare the results offline. A per-row salt frustrates that comparison, but it also removes the fast deterministic lookup your login flow needs.

A password hash solves a different problem. The server needs to test a submitted password, but it never needs to recover the password. An application usually does need to recover an email address to send a confirmation, receipt, alert, or reset message.

A keyed HMAC gives deterministic lookup without putting the secret in the database. I would compute a value like this through a maintained cryptographic library:

HMAC-SHA-256(
  lookup_key_v3,
  tenant_id || 0x00 || canonical_email
)

The tenant prefix makes the same address produce different account keys in different tenants. If the product requires global uniqueness, use a global scope instead. Keep the HMAC key independent from every encryption key.

This protects against offline guessing when an attacker gets only the database. It does not help if the attacker also obtains the lookup key or can ask the live application to compute arbitrary lookups. The FTC's July 2024 guidance on hashed identifiers is blunt on the privacy boundary: a persistent hash can still identify or target a person. Treat an HMAC lookup value as pseudonymous personal data, not harmless telemetry.

What belongs inside the encrypted email box?

I would encrypt one small structured record rather than scatter address variants across columns:

{
  "schema": 1,
  "original": "Usama@Example.COM",
  "delivery": "Usama@example.com",
  "canonical": "Usama@example.com"
}

The example is hypothetical. The exact canonical value depends on a documented identity policy. Domain comparison is case-insensitive, while blindly lowercasing or rewriting the local part can merge distinct accounts. The email case-sensitivity storage model owns that deeper normalization decision.

OWASP's email identity guidance recommends preserving the original value and keeping a separate canonical form used consistently across registration, login, recovery, and account linking. Encrypting them together keeps both representations behind the same authorization boundary.

Use a maintained authenticated-encryption facility, not custom cryptography. OWASP's cryptographic storage guidance recommends starting from the threat model, using authenticated modes such as GCM or CCM, keeping independent keys independent, and planning rotation before it is urgent.

The envelope should carry the algorithm or format version, key identifier, nonce, authentication tag, and ciphertext. Bind the ciphertext to its location with associated data such as the tenant ID, user ID, field name, and schema version. That prevents a valid encrypted email box from being copied onto a different user record without authentication failing.

Disk or database encryption still has value for stolen media and backups. Application-layer encryption adds a different boundary against a database snapshot or read-only database compromise. It does not stop an attacker who fully controls the application and its decryption path. That is the strongest fair objection to this design, and it is correct. The answer is scoped keys, narrow service permissions, audit logs, and rate limits around decryption, not a claim that ciphertext makes a compromised application safe.

How should signup, login, and sending use the two values?

The signup path should perform one transaction:

  1. Validate the input and derive the original, delivery, and canonical values under one policy version.
  2. Compute the active HMAC lookup value and test the relevant uniqueness boundary.
  3. Encrypt the structured email box with fresh library-managed randomness and associated data.
  4. Insert the user and lookup row together.

Login takes the submitted address through the same canonicalization policy, computes the active lookup HMAC, and queries the lookup table. During a rotation window it may compute both active and previous versions. Public responses must still hide whether a record exists. The email enumeration guide covers that response and timing boundary.

A mail worker should receive a user or delivery ID, authorize the message purpose, decrypt the address just in time, and hand it to the sending provider. Its logs can record the user ID, message ID, template, result, and interaction ID without copying the full address into another retention system. OWASP's logging guidance calls out email addresses as personal data that may need deletion, masking, pseudonymization, or encryption before logging.

Do not put the address in a URL, queue name, metric label, trace span name, cache key, or analytics property. Encryption in the primary table means little if plaintext copies spread through systems with wider access and longer retention.

Where does email verification evidence go?

Verification is evidence about an address at a point in time. It is not the address's storage format and it does not prove inbox ownership.

The current emailverifier.dev API contract returns status, action, flagged, signals, and an optional suggestion. It keeps inconclusive mailbox checks explicit as unknown, and an allow recommendation does not guarantee delivery or ownership.

I would store the accepted address in the encrypted box, then attach only the evidence the product actually uses: the check time, technical status, action, signals, and policy version. Keep a typo suggestion transient until the user chooses it. Never silently replace the protected address with a suggested one.

This separation also makes reverification honest. A later check can append or replace a verification snapshot without rewriting the user's identity or pretending that an older result is current. The production API-results guide shows how deliverable, risky, undeliverable, and unknown outcomes should remain separate in application policy.

Can you rotate both keys without breaking login?

Yes, if each row is versioned and reads support a controlled overlap. I would rotate in this order:

  1. Make the new encryption and lookup keys active for new writes.
  2. Keep the previous keys available for reads.
  3. For each existing user, decrypt the email box, write a new lookup digest row, and re-encrypt the box under the new version.
  4. Switch lookups to the new version after coverage is measured, not after a guessed deadline.
  5. Remove old lookup rows, then retire old keys only after the backup and rollback policy permits it.

The new HMAC cannot be derived from the old HMAC. Rotation therefore needs temporary access to the canonical plaintext through the authorized decrypt path. A uniqueness constraint that includes key_version lets both digests coexist during the backfill.

NIST SP 800-57 Part 1 treats generation, use, rotation, recovery, and destruction as one key-management lifecycle. Test the unhappy paths before production: a missing old key, a partial backfill, a rolled-back deployment, a restored backup, and two signups racing for the same address.

What should deletion actually remove?

Deleting the ciphertext but leaving the lookup digest is not automatically anonymous deletion. The digest can still match future input and can continue to identify a returning person inside the system.

Define the purpose and retention period for each copy: active account identity, pending email change, delivery queue, support export, suppression record, verification evidence, application log, and backup. The periods do not have to match, but each needs an owner and an expiry or documented retention reason.

If the product must retain a suppression match after account deletion, keep it in a separate purpose-limited store with its own HMAC key, access policy, and retention decision. Do not call that record anonymous. Legal obligations differ by jurisdiction and message type, so the requirement needs product and legal review rather than a universal number of days.

The UK Information Commissioner's Office, for example, says organizations should justify retention periods, review them, and erase or anonymize personal data when it is no longer needed. Its storage-limitation guidance also notes that UK GDPR does not prescribe one period for every data type.

A deletion test should start with one controlled account and follow it through the primary database, lookup table, caches, queues, logs, exports, replicas, and the documented backup lifecycle. The test passes only when the application can no longer recover or match the address outside the exact retention exceptions your policy names.

Continue reading