Hash an email

The email normalisation rules, a reference implementation, and test vectors

Every member must turn an email into exactly the same hash, or the registry cannot match a user across platforms. This page is the specification. Implement it once, test it against the vectors below, and use the same function for checks and for reports.

The rules#

Apply these steps in order:

  1. Remove whitespace from the start and end. Apply Unicode NFKC normalisation. Convert to lowercase.

  2. If whitespace remains inside the value, it is not an email.

  3. Split the value at the last @ into a local part and a domain. If either part is empty, the value is not an email.

  4. If the domain ends with one ., remove it. If the domain is then empty, the value is not an email.

  5. If the domain is googlemail.com, change it to gmail.com.

  6. If the domain is one of the providers below, remove everything from the first + in the local part:

    gmail.com, outlook.com, hotmail.com, live.com, msn.com, icloud.com, me.com, mac.com, proton.me, protonmail.com, pm.me, fastmail.com, fastmail.fm

  7. If the domain is gmail.com, remove every . from the local part.

  8. If the local part is now empty, the value is not an email.

  9. Join the local part, @ and the domain. Hash the result with SHA-256, and encode the digest as lowercase hexadecimal (64 characters).

Step 1 lowercases the whole address, including the local part. Almost no mail system treats the local part as case-sensitive, and if the hash kept case, a user could avoid a report by changing the case of one letter.

If a value is not an email after these steps, do not check it and do not report it.

Why these rules and no others#

The rules only join spellings that a mail provider delivers to the same inbox. Gmail ignores dots, and the listed providers deliver name+anything@ to name@. Other providers and company domains can treat + and . as part of the address, so the rules leave them alone. Joining two different inboxes would make two different people match, and a false match can refuse an innocent person a service.

Reference implementation#

This JavaScript (Node.js 18 or later) implementation matches the specification and passes every test vector on this page.

JavaScript
import { createHash } from 'node:crypto';

const PLUS_ADDRESSING_DOMAINS = new Set([
  'gmail.com',
  'outlook.com',
  'hotmail.com',
  'live.com',
  'msn.com',
  'icloud.com',
  'me.com',
  'mac.com',
  'proton.me',
  'protonmail.com',
  'pm.me',
  'fastmail.com',
  'fastmail.fm',
]);

export function normaliseEmail(raw) {
  const value = raw.trim().normalize('NFKC').toLowerCase();
  const at = value.lastIndexOf('@');
  if (at <= 0 || at === value.length - 1 || /\s/.test(value)) return null;

  let local = value.slice(0, at);
  let domain = value.slice(at + 1);
  if (domain.endsWith('.')) domain = domain.slice(0, -1);
  if (!domain) return null;
  if (domain === 'googlemail.com') domain = 'gmail.com';

  if (PLUS_ADDRESSING_DOMAINS.has(domain)) {
    const plus = local.indexOf('+');
    if (plus >= 0) local = local.slice(0, plus);
  }
  if (domain === 'gmail.com') local = local.replaceAll('.', '');
  return local ? `${local}@${domain}` : null;
}

export function registryDigest(email) {
  const normalised = normaliseEmail(email);
  return normalised && createHash('sha256').update(normalised).digest('hex');
}

Use the same function for every call:

JavaScript
const digest = registryDigest(signupEmail);
if (digest === null) {
  // Not an email address. Skip the registry for this value.
}

Test vectors#

Run your implementation against every row before you send a request. The SHA-256 column is the exact value to send.

Input Normalised SHA-256
Jane.Doe@Example.com jane.doe@example.com 86e0b9e56c17cc4d12387e1949b85053fbe73bc3ce5a1188713a9d300cc6133d
␠␠jane.doe@example.com␠␠ jane.doe@example.com 86e0b9e56c17cc4d12387e1949b85053fbe73bc3ce5a1188713a9d300cc6133d
J.a.n.e.D.o.e+promo@gmail.com janedoe@gmail.com d6117306485ed0e50afab3ac871e98f81699151f30281527d63ff5f233656c69
janedoe@googlemail.com janedoe@gmail.com d6117306485ed0e50afab3ac871e98f81699151f30281527d63ff5f233656c69
jane+news@outlook.com jane@outlook.com 5a0b70300b36ef454b660f484ad2a9353e2b4e199f623fe55789406eb07055f5
jane+news@example.com jane+news@example.com 5a70ec8b461e4cac30f3a4b74cc1281e8ae664423f94429b738152ca14d81b77
jane-news@yahoo.com jane-news@yahoo.com 394afddf1f3a96c4f742ab6ca3c470a1b8a5eb386eddcb27e9ca88ce2df71355
jane.doe@example.com. jane.doe@example.com 86e0b9e56c17cc4d12387e1949b85053fbe73bc3ce5a1188713a9d300cc6133d
JANE@example.com jane@example.com 8c87b489ce35cf2e2f39f80e282cb2e804932a56a213983eeeb428407d43b52d
+tag@gmail.com Not an email None
not-an-email Not an email None
@example.com Not an email None

In the input column, ␠ marks a space character.

Common mistakes#

Hashing the raw email

Hash the normalised value. Jane.Doe@Example.com and jane.doe@example.com must produce the same digest.

Uppercase or base64 output

Send lowercase hexadecimal. The API rejects any other format with 400 INVALID_REQUEST.

Hashing twice

Hash the normalised email once. Do not hash the hexadecimal digest again before you send it.

Applying Gmail rules to every domain

Remove dots only for gmail.com, and remove +tags only for the listed providers. first.last+news@company.com stays exactly as it is.

Splitting at the first @

Split at the last @. A quoted local part can contain @.

Different code for checks and reports

Use one function for both. If your check and your report hash differently, your own reports never match your own checks, and nobody else's do either.

Versioning#

The rules on this page are version 1. Every check response includes normalisation_version, so you can confirm which rules the registry expects. If the rules change, the version number changes, and members receive notice before the change.