← All articles

How to verify a user's email and link it to their wallet

Require two proofs: the user can read the mailbox, and the wallet agrees. A Lit Action signs the verification link, emails it directly, and checks it when the user comes back, so your backend never holds a link it could use itself. Here is the flow and the code.

Lit Protocol

To link an email address to a wallet, require two proofs: the user can read the mailbox, and the wallet agrees to the link. On Lit Protocol, a Lit Action handles the email side. It checks a request signed by the wallet, signs a verification link, and emails it straight to the address. When the user opens the link, they sign again with the same wallet, and the action checks both before it signs an authorization for your registry contract. The contract stores an opaque identifier for the email.

The key property is that your backend never sees the verification link before the user opens it. If it did, it could verify any email for a wallet it controls without access to the mailbox.

The flow

The user enters their email and signs a registration request with their wallet. Your backend forwards that request to the Lit Action. The action checks the signature, reads the current state from the registry, signs a credential, and emails it inside a link. It returns only an acknowledgement to your backend.

The user opens the link and signs a confirmation with the same wallet. Your backend forwards the credential and the confirmation to the action. The action checks everything again and returns a short-lived authorization. Your backend submits that authorization to the registry, and the registry checks the signature and records the link.

The browser builds a fixed message and the wallet signs it. The message names the app, the chain and registry, the email, the wallet, the wallet's current version in the registry, and a deadline.

// shared by the browser and the action
function issueMessage(r) {
  return JSON.stringify(["ExampleApp email registration", 1, r.chainId, r.registry.toLowerCase(),
    canonicalEmail(r.email), r.wallet.toLowerCase(), r.walletVersion, r.deadline]);
}

Build this message in the browser from the fixed format. Check sign-in challenges in the browser too, so your backend cannot present a registration message as a sign-in request. A five-minute deadline in the interface, and a ten-minute maximum in the action, is a good starting point.

The action checks the request, then builds a credential with the email, its identifier, the wallet, the deployment, the current versions from the registry, and a 15-minute expiry. It signs the credential with its own key and emails it.

// inside the Lit Action, after checking the wallet signature and deadline.
// emailIdentifier calls your Lit wallet first, so this fails for any other account.
const emailId = await emailIdentifier(CHAIN_ID, REGISTRY, email);
// read signerEpoch, emailVersion, and walletVersion from the registry, and check this action is the approved signer
const claims = { email, emailId, wallet, chainId: CHAIN_ID, registry: REGISTRY, signerEpoch, emailVersion, walletVersion,
  issuedAt: now, expiresAt: now + 15 * 60 };
const signer = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
const credential = { claims, signature: await signer.signMessage(credentialMessage(claims)) };
await sendVerificationEmail(email, encodeCredential(credential));
return { ok: true, emailId };

emailIdentifier computes the private identifier for the email. The post on storing email addresses on-chain without making them public shows how it works. It uses a Lit wallet that belongs to your account. The action's signing key is the same for anyone who runs the same code, so every path that signs must call your wallet first and must not catch that error. Another account running the same code then fails before it signs anything. sendVerificationEmail decrypts your email provider's key inside the action and sends a fixed template. The post on sending email without exposing your API key covers that part.

The action returns the identifier but not the credential. Keep credentials out of responses, logs, and error messages until they are used. Send a credential even when another wallet already owns the email, and reject the ownership conflict later. That way, the response to the first step does not say whether the address is already registered. Your application still gets the identifier, so it can check the registry itself.

The credential goes in the link's fragment, after the #. Browsers do not send the fragment in the HTTP request, so it stays out of ordinary request logs. Scripts on your confirmation page can still read it, so be careful with analytics on that page.

Step 3: The user confirms with the wallet

Opening the link only shows a confirmation screen. The user then signs a second message with the wallet named in the credential. The message includes the hash of the credential and a new deadline.

function redeemMessage(credential, deadline) {
  return JSON.stringify(["ExampleApp redeem email", 1,
    ethers.utils.hashMessage(credentialMessage(credential.claims)), deadline]);
}

Because this needs a wallet signature, a mail scanner that opens the link cannot complete the registration, and neither can someone who only has a leaked link.

Step 4: The action authorizes the registry

The action checks the credential's signature, the deployment, the timestamps, and the wallet's confirmation. It recomputes the email identifier and reads the registry again, and it rejects the request if the versions changed or the email belongs to another wallet. Then it signs an authorization for the contract.

if (await emailIdentifier(CHAIN_ID, REGISTRY, c.email) !== c.emailId) throw new Error("identifier mismatch");
const signer = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
const registration = { emailId: c.emailId, wallet: c.wallet, emailVersion: c.emailVersion,
  walletVersion: c.walletVersion, signerEpoch: c.signerEpoch,
  deadline: Math.min(c.expiresAt, r.deadline, now + 120) };
const signature = await signer._signTypedData(domain, registrationTypes, registration);
return { email: c.email, registration, signature };

The authorization lasts at most two minutes, and it cannot outlive the credential or the confirmation. It uses EIP-712 with a domain that names your app, a version, the chain, and the registry contract, and the contract must use the same domain and field order. Your backend keeps the email for its own private table and sends only the registration and the signature to the contract. The plain email never appears in the transaction, its events, or the contract's storage.

The registry stops replays

An expiry is not enough on its own. Imagine a user verifies an email, then removes it, and someone submits an old authorization before it expires. To stop that, the registry keeps a version number for every email identifier and every wallet, and it never resets them.

require(
    r.emailVersion == emailVersions[r.emailId] &&
    r.walletVersion == walletVersions[r.wallet],
    "stale version"
);
// after the signature, expiry, epoch, and ownership checks, and removing any previous link:
walletOf[r.emailId] = r.wallet;
emailOf[r.wallet] = r.emailId;
emailVersions[r.emailId]++;
walletVersions[r.wallet]++;

The contract checks the versions when the transaction runs, which closes the gap between the action's read and the submission. The first registration to land cancels any other authorization for the same versions. Removing a link also advances the versions, so outstanding links stop working. Each wallet has one active email, and each email has one active wallet. Access to a mailbox alone cannot move an existing link to a different wallet, so account recovery needs its own process.

Setting it up

Create a Lit wallet for the directory and the sealed email key, and keep it for the life of the directory. Write the registry address, chain ID, RPC URL, sender, link origin, and wallet ID into the action's code, and do not take them from the request. Seal a sending-only email key to it. Put the registration action and that wallet in a group, and give your backend a usage key that can only run that group. Ask the action for its signing address and register it in the contract as the approved signer.

When you change the action, keep the same wallet so email identifiers stay the same. Register the new action's signing address in place of the old one, remove the old action from the group, and advance the signer epoch so links issued before the change stop working. Sending a new email does not cancel earlier links, which stay valid until they expire or a version changes.

What this protects and what it does not

The registration depends on the mailbox and the wallet. Your backend cannot complete a verification on its own, and the chain never stores a plain email address. Your application and your email provider still see the email addresses. Anyone who can read sent emails in Resend can also read the links, so keep full-access Resend keys and dashboard access away from whoever runs your backend. Because the local part keeps its case, Alice@ and alice@ count as different addresses, so decide whether that fits your users. The contract trusts the approved action signer, so its safety depends on which code can use that signer and on who can change the approved signer. Before production, test tampered credentials, the wrong wallet, expiry, double redemption, competing registrations, revocation, and signer rotation.

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.