How to store email addresses on-chain without making them public or guessable
A plain hash of an email address can be guessed. Use a keyed hash whose key only exists inside a secure enclave, so the chain stores an identifier that nobody can test guesses against without going through your action. Here is how to derive it in a Lit Action.
To keep email addresses private in an on-chain directory, store a keyed hash of each address instead of the address or a plain hash. A plain hash such as keccak256 of an email can be guessed, because anyone can hash a list of likely addresses and compare. A keyed hash, called an HMAC, needs a secret key to compute. On Lit Protocol, that key is derived from a Lit wallet inside a secure enclave, so nobody can compute identifiers outside the code you approve. The chain stores only the identifier and the wallet it belongs to.
Why a plain hash fails
Email addresses are easy to guess. There are large public lists of them, and many follow simple patterns like first.last@company.com. If a registry stores keccak256(email), anyone can hash a candidate and look for a match. A public salt does not stop this, because the attacker can hash each guess with the same salt. The data also stays on the chain for good, so a guess can be tried years later.
Decide what an address means
Before you compute anything, decide how to normalize addresses, and use the same function everywhere. One reasonable choice is to accept ASCII addresses, lowercase the domain, keep the local part's case, and keep dots and plus tags. With that rule, Alice@Example.com becomes Alice@example.com. If two parts of your system normalize differently, the same person gets two identifiers.
Derive the identifier inside a Lit Action
The action asks for the private key of a Lit wallet that belongs to your account, derives a directory key from it, and computes the identifier. Lit only hands over the wallet's key when the wallet and the action's code are in the same group in your account.
// email-id.js
const PKP = "0xYourDirectoryWallet...";
async function emailIdentifier(chainId, registry, canonicalEmail) {
const hex = (await Lit.Actions.getPrivateKey({ pkpId: PKP })).replace(/^0x/, "");
const raw = ethers.utils.arrayify("0x" + hex);
const enc = new TextEncoder();
const root = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const dirKey = await crypto.subtle.sign("HMAC", root, enc.encode("ExampleApp email directory key v1"));
const key = await crypto.subtle.importKey("raw", dirKey, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const input = JSON.stringify(["exampleapp-email-v1", `${chainId}:${registry.toLowerCase()}`, canonicalEmail]);
return ethers.utils.hexlify(new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(input))));
}The label "ExampleApp email directory key v1" keeps this key separate from anything else you do with the wallet. The chain ID and registry address make identifiers specific to one deployment, so the same email gets a different identifier on a test registry. Because the key comes from a wallet, you can publish a new version of the action and every identifier stays the same.
Keep that wallet safe and do not replace it. If it is lost, you can no longer compute existing identifiers, and the directory loses its continuity.
Looking up a wallet
The identifier cannot be reversed, so lookups need your own database. Store the plaintext email, the identifier, the wallet, and the status in a private table, and answer lookups from there. To check whether an email is registered, look it up in that table. Do not add an action that computes identifiers on request.
Be careful with any endpoint that computes identifiers for callers. If anyone can ask for the identifier of any address, they can test guesses through that endpoint, and the keyed hash stops protecting those addresses. Keep identifier computation inside the flows that need it, such as registration. A rate limit in your API does not limit someone who holds the usage key, so keep that key on your server. Each call to the wallet counts toward Lit's limit of ten key operations per run, so derive the directory key once if you compute several identifiers in one run.
What this protects
Someone reading the chain sees identifiers and wallets, and cannot work out which email belongs to which wallet without the key. Your application and your email provider still see the email addresses. If your database is compromised, its mappings are exposed. A wallet's public activity is still public, and the registry's history shows when a wallet registered or changed its email.
Where this fits
Our guide to verifying a user's email and linking it to their wallet, which comes out on Wednesday, November 11, uses this identifier when it writes a verified email to the registry.
Get started
To create an account, go to the Lit dashboard and click Create an account. The Lit Actions runtime is documented at developer.litprotocol.com/lit-actions.