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:
-
Remove whitespace from the start and end. Apply Unicode NFKC normalisation. Convert to lowercase.
-
If whitespace remains inside the value, it is not an email.
-
Split the value at the last
@into a local part and a domain. If either part is empty, the value is not an email. -
If the domain ends with one
., remove it. If the domain is then empty, the value is not an email. -
If the domain is
googlemail.com, change it togmail.com. -
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 -
If the domain is
gmail.com, remove every.from the local part. -
If the local part is now empty, the value is not an email.
-
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.
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:
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.comandjane.doe@example.commust 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+tagsonly for the listed providers.first.last+news@company.comstays 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.