How to send email without exposing your email provider's API key
Encrypt the email provider's API key to a Lit wallet, decrypt it only inside the Lit Action that sends the email, and pin the sender and template in the code. Here is how to set that up with Resend.
To send email without exposing your email provider's API key, keep the key encrypted and only decrypt it inside the code that sends the email. On Lit Protocol, you encrypt the key once to a Lit wallet that belongs to your account. A Lit Action, which is a JavaScript function that runs inside a secure enclave, decrypts it when it needs to send a message and calls the provider directly. Your application never holds the key. It asks the action to send an email, and the action decides what that email can say.
I will use Resend as the provider, but the same steps work for any email API that takes a bearer key.
Seal the key once
Create a sending-only API key in Resend. Then create a Lit wallet in the Lit dashboard or with the API. Lit uses the wallet's key to encrypt and decrypt data, and only actions that you put in a group with the wallet can use it.
Run this action once to encrypt the Resend key to that wallet.
// seal-resend-key.js: run once, then remove it from the group
async function main({ pkpId, resendKey }) {
if (!resendKey.startsWith("re_")) throw new Error("not a Resend key");
return { ciphertext: await Lit.Actions.Encrypt({ pkpId, message: resendKey }) };
}Call it with the X-Privacy-Mode: true header, so Lit's server does not write the request or the result to its logs. Store the ciphertext in your configuration. It is useless without the wallet, and the wallet only works inside actions you approve. After you have the ciphertext, remove this action from the group, so the group only holds the code that sends email. The removal can take a few minutes to take effect.
Send from the action
This action sends a verification email. The sender, the subject, and the template are written into the code, so a caller can choose the recipient and the link token but not what the email says or who it comes from.
// send-verification.js
const PKP = "0xYourLitWallet...";
const RESEND_CIPHERTEXT = "..."; // from the sealing step
const SENDER = "ExampleApp <verify@example.com>";
const ORIGIN = "https://app.example.com"; // the only place links can point
async function main({ to, token }) {
if (typeof to !== "string" || !/^[^\s@\r\n]+@[^\s@\r\n]+$/.test(to)) throw new Error("invalid address");
if (typeof token !== "string" || !/^[A-Za-z0-9_%.~-]{16,2048}$/.test(token)) throw new Error("invalid token");
const apiKey = await Lit.Actions.Decrypt({ pkpId: PKP, ciphertext: RESEND_CIPHERTEXT });
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(15000),
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
from: SENDER,
to: [to],
subject: "Verify your email",
text: `Open this link to confirm your email:\n\n${ORIGIN}/verify#${token}\n\n` +
"The link expires in 15 minutes. If you did not ask for this, ignore this email.",
}),
});
if (!res.ok) throw new Error("email delivery failed");
return { sent: true };
}The action refuses redirects, so the request that carries the key can only go to Resend's API. It returns only whether the email went out. The API key never appears in the response, and the error messages do not include it.
Put this action and the wallet in a group, and give your application a usage key that can only run that group. The post on adding wallets to your app with an API shows those calls.
What the setup protects
Your servers, logs, and environment variables never contain the Resend key. If someone steals the usage key, they can send verification emails in your fixed template. That can still cost you sending quota and reputation. A rate limit in your application does not stop someone who holds the usage key, because they can call Lit directly, so keep the usage key on your server and watch your usage in Resend. Use a key with sending access only, restricted to your sending domain, so a leak cannot read your past messages or change your settings. Resend, and anyone who can read sent messages there, can still see the emails themselves.
If you need to change the template or the sender, publish a new version of the action, add it to the group, and remove the old version. The ciphertext keeps working, because it is tied to the wallet.
Where this fits
Sending email is often one step in a larger flow. Our guide to verifying a user's email and linking it to their wallet, which comes out on Wednesday, November 11, builds on this action. There, the action signs the link token itself, so your application never sees a token it could use to verify an email on its own.
Get started
To create an account, go to the Lit dashboard and click Create an account. Secrets inside actions are covered at developer.litprotocol.com/lit-actions/secrets.