Signing and Encryption
Learn how to sign and encrypt email messages using DKIM and S/MIME
Last updated:
Signing and Encryption
The email package supports signing and encrypting email messages to increase their integrity and security. Both options can be combined to encrypt a signed message and/or to sign an encrypted message.
Prerequisites
Before signing/encrypting messages, make sure to have:
- For DKIM: A valid DKIM private key (PEM format)
- For S/MIME: Valid S/MIME security certificates and private keys
- For S/MIME: The
pkijsandasn1jspackages installed (required for S/MIME operations)
npm install pkijs asn1jsWhen using OpenSSL to generate certificates, make sure to add the -addtrust emailProtection command option for S/MIME certificates.
Signing Messages
When signing a message, a cryptographic hash is generated for the entire content of the message (including attachments). This hash is added as an attachment or header so the recipient can validate the integrity of the received message. However, the contents of the original message are still readable for mailing agents not supporting signed messages, so you must also encrypt the message if you want to hide its contents.
You can sign messages using either S/MIME or DKIM. In both cases, the certificate and private key must be PEM encoded, and can be either created using OpenSSL or obtained at an official Certificate Authority (CA). The email recipient must have the CA certificate in the list of trusted issuers in order to verify the signature.
Note: If you use message signature, sending to Bcc will be removed from the message. If you need to send a message to multiple recipients, you need to compute a new signature for each recipient.
DKIM Signer
DKIM is an email authentication method that affixes a digital signature, linked to a domain name, to each outgoing email message. It requires a private key but not a certificate.
import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { createDkimSigner } from "@visulima/email/crypto";
const mail = createMail(resendProvider({ apiKey: "re_xxx" }));
// Create DKIM signer
const dkimSigner = createDkimSigner({
domainName: "example.com",
keySelector: "default",
privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
// Optional: passphrase if the key is encrypted
// passphrase: "your-passphrase",
});
// Sign and send email
const message = new MailMessage().to("user@example.com").from("sender@example.com").subject("Hello").html("<h1>Hello World</h1>").sign(dkimSigner);
const result = await mail.send(message);read_file
DKIM Options
The DKIM signer supports various configuration options:
const dkimSigner = createDkimSigner({
domainName: "example.com",
keySelector: "default",
// Private key can be:
// - Direct key content: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
// - File path (Node.js, Bun, Deno): "file:///path/to/private-key.pem"
privateKey: "file:///path/to/private-key.pem",
passphrase: "optional-passphrase",
// Canonicalization algorithms
headerCanon: "relaxed", // 'simple' (default) or 'relaxed'
bodyCanon: "relaxed", // 'simple' (default) or 'relaxed'
// Headers to ignore when signing
headersToIgnore: ["Message-ID", "X-Custom-Header"],
});Platform Support:
- Node.js: Full support including file-based keys (
file://paths) - Bun: Full support including file-based keys (
file://paths) - Deno: Full support including file-based keys (
file://paths) - Cloudflare Workers/workerd: Full support - requires
nodejs_compatflag and key content as strings (no file paths). Usesunenvpolyfills fornode:cryptocompatibility (unenv). - Other environments: Provide key content directly (no file support)
S/MIME Signer
S/MIME is a standard for public key encryption and signing of MIME data. It requires using both a certificate and a private key.
import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { createSmimeSigner } from "@visulima/email/crypto";
const mail = createMail(resendProvider({ apiKey: "re_xxx" }));
// Create S/MIME signer
const smimeSigner = createSmimeSigner({
certificate: "/path/to/certificate.crt",
privateKey: "/path/to/private-key.key",
// Optional: passphrase if the key is encrypted (supported in Node.js)
// passphrase: "your-passphrase",
// Optional: intermediate certificates
// intermediateCerts: ["/path/to/intermediate.crt"],
});
// Sign and send email
const message = new MailMessage().to("user@example.com").from("sender@example.com").subject("Hello").html("<h1>Hello World</h1>").sign(smimeSigner);
const result = await mail.send(message);Platform Support:
- Node.js 20+: Full support including encrypted private keys (PBES2/PBKDF2). Uses Node.js crypto APIs.
- Bun: Full support via Node.js compatibility layer. Encrypted keys require Node.js crypto.
- Deno: Full support via Node.js compatibility layer. Encrypted keys require Node.js crypto.
- Cloudflare Workers/workerd: Not supported - requires
node:fs/promisesfor file reading andnode:cryptofor key handling. Code would need modification to accept strings instead of file paths.
Encrypting Messages
When encrypting a message, the entire message (including attachments) is encrypted using a certificate. Therefore, only the recipients that have the corresponding private key can read the original message contents.
S/MIME Encrypter
import { createMail } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { createSmimeEncrypter } from "@visulima/email/crypto";
const mail = createMail(resendProvider({ apiKey: "re_xxx" }));
// Single certificate for all recipients
const encrypter = createSmimeEncrypter({
certificates: "/path/to/recipient-certificate.crt",
});
// Or, use different certificates per recipient
const perRecipientEncrypter = createSmimeEncrypter({
certificates: {
"jane@example.com": "/path/to/jane-certificate.crt",
"john@example.com": "/path/to/john-certificate.crt",
},
// Optional: encryption algorithm
// Supported: 'aes-256-cbc', 'aes-192-cbc', 'aes-128-cbc'
// Default: 'aes-256-cbc'
// algorithm: "aes-256-cbc",
});
// Encrypt and send email
const message = new MailMessage().to("user@example.com").from("sender@example.com").subject("Hello").html("<h1>Hello World</h1>").encrypt(encrypter);
const result = await mail.send(message);Platform Support:
- Node.js 20+: Full support. Uses Node.js crypto APIs and Web Crypto API.
- Bun: Full support via Node.js compatibility layer and Web Crypto API.
- Deno: Full support via Node.js compatibility layer and Web Crypto API.
- Cloudflare Workers/workerd: Not supported - requires
node:fs/promisesfor file reading. Code would need modification to accept strings instead of file paths.
Encryption Algorithms:
aes-256-cbc(default) - AES-256 in CBC modeaes-192-cbc- AES-192 in CBC modeaes-128-cbc- AES-128 in CBC mode
Combining Signing and Encryption
You can combine signing and encryption. The message will be signed first, then encrypted:
import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { createDkimSigner, createSmimeEncrypter } from "@visulima/email/crypto";
const mail = createMail(resendProvider({ apiKey: "re_xxx" }));
const dkimSigner = createDkimSigner({
domainName: "example.com",
keySelector: "default",
privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
});
const encrypter = createSmimeEncrypter({
certificates: "/path/to/recipient-certificate.crt",
});
// Sign then encrypt
const message = new MailMessage()
.to("user@example.com")
.from("sender@example.com")
.subject("Hello")
.html("<h1>Hello World</h1>")
.sign(dkimSigner)
.encrypt(encrypter);
const result = await mail.send(message);Important Notes
-
Message Rendering: Signing and encrypting messages require their contents to be fully rendered. For example, the content of templated emails is rendered by template engines. So, if you want to sign and/or encrypt such a message, make sure the template is rendered before signing/encrypting.
-
BCC Handling: When using message signature, BCC recipients will be removed from the message. If you need to send a message to multiple recipients, you need to compute a new signature for each recipient.
-
Certificate Management: Ensure that certificates used for signing and encryption are valid and trusted by the recipients.
-
Private Key Security: Always handle private keys securely. Avoid hardcoding them in your codebase; use environment variables or secure vaults instead.
-
Encrypted Private Keys:
- Node.js: Full support for encrypted private keys (PBES2/PBKDF2). Simply provide the
passphraseoption. Node.js crypto automatically handles encrypted PKCS#8 and PKCS#1 keys. - Bun/Deno: Encrypted keys work via Node.js compatibility layer. If compatibility is not available, decrypt the key externally or use Node.js for automatic decryption.
- Cloudflare Workers/workerd: Full support for encrypted keys via
unenvpolyfills. Provide thepassphraseoption just like in Node.js (unenv).
- Node.js: Full support for encrypted private keys (PBES2/PBKDF2). Simply provide the
Platform Compatibility
| Feature | Node.js | Bun | Deno | Cloudflare Workers |
|---|---|---|---|---|
| DKIM Signing | ✅ | ✅ | ✅ | ✅* |
| S/MIME Signing | ✅** | ✅** | ✅** | ❌*** |
| S/MIME Encryption | ✅** | ✅** | ✅** | ❌*** |
* DKIM in Cloudflare Workers: Full support - requires nodejs_compat flag and key content as strings (no file paths). Uses unenv polyfills for node:crypto compatibility (unenv). All required functions (createHash, createSign, createPrivateKey) are available.
** S/MIME requires Web Crypto API (Node.js 20+, Bun, Deno) and pkijs/asn1js packages. All runtimes use Node.js APIs via compatibility layers where available.
*** S/MIME in Cloudflare Workers: Not supported - requires node:fs/promises for file reading. Current implementation expects file paths, not strings. Code would need modification to accept strings instead of file paths.
Examples
Example: DKIM Signing with File-based Key
import { createMail, MailMessage } from "@visulima/email";
import { smtpProvider } from "@visulima/email/providers/smtp";
import { createDkimSigner } from "@visulima/email/crypto";
const mail = createMail(
smtpProvider({
host: "smtp.example.com",
port: 587,
auth: {
user: "user@example.com",
pass: "password",
},
}),
);
// Option 1: Use file:// path (works in Node.js, Bun, Deno)
const dkimSigner = createDkimSigner({
domainName: "example.com",
keySelector: "default",
privateKey: "file:///path/to/dkim-private-key.pem",
headerCanon: "relaxed",
bodyCanon: "relaxed",
});
// Option 2: Read file manually (Node.js example)
// import { readFileSync } from "node:fs";
// const privateKey = readFileSync("/path/to/dkim-private-key.pem", "utf-8");
// const dkimSigner = createDkimSigner({
// domainName: "example.com",
// keySelector: "default",
// privateKey,
// });
const message = new MailMessage()
.to("recipient@example.com")
.from("sender@example.com")
.subject("Signed Email")
.html("<h1>This email is signed with DKIM</h1>")
.sign(dkimSigner);
await mail.send(message);Example: S/MIME Signing and Encryption
import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { createSmimeSigner, createSmimeEncrypter } from "@visulima/email/crypto";
const mail = createMail(resendProvider({ apiKey: "re_xxx" }));
const signer = createSmimeSigner({
certificate: "/path/to/sender-certificate.crt",
privateKey: "/path/to/sender-private-key.key",
passphrase: "optional-passphrase",
});
const encrypter = createSmimeEncrypter({
certificates: {
"recipient1@example.com": "/path/to/recipient1-certificate.crt",
"recipient2@example.com": "/path/to/recipient2-certificate.crt",
},
});
const message = new MailMessage()
.to(["recipient1@example.com", "recipient2@example.com"])
.from("sender@example.com")
.subject("Secure Email")
.html("<h1>This email is signed and encrypted</h1>")
.sign(signer)
.encrypt(encrypter);
await mail.send(message);Cloudflare Workers Support
Current Status:
- DKIM Signing: ✅ Full support - works with
nodejs_compatflag usingunenvpolyfills - S/MIME Signing/Encryption: ❌ Not supported - requires
node:fs/promisesfor file reading
DKIM in Cloudflare Workers:
- ✅ All required
node:cryptofunctions are available viaunenvpolyfills (unenv) - ✅
createHash,createSign, andcreatePrivateKeyare all supported - ✅ Works with key content as strings (no file paths needed)
- ✅ Encrypted keys are supported (via
createPrivateKeyfrom unenv)
Configuration: Enable nodejs_compat in your wrangler.toml:
[env.production]
compatibility_flags = ["nodejs_compat"]Example for Cloudflare Workers:
import { createMail, MailMessage } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { createDkimSigner } from "@visulima/email/crypto";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const mail = createMail(resendProvider({ apiKey: env.RESEND_API_KEY }));
const dkimSigner = createDkimSigner({
domainName: "example.com",
keySelector: "default",
privateKey: env.DKIM_PRIVATE_KEY, // Must be string, not file:// path
// Optional: passphrase for encrypted keys (supported via unenv)
// passphrase: env.DKIM_PASSPHRASE,
});
const message = new MailMessage()
.to("recipient@example.com")
.from("sender@example.com")
.subject("Signed Email")
.html("<h1>This email is signed with DKIM</h1>")
.sign(dkimSigner);
await mail.send(message);
return new Response("Email sent");
},
};S/MIME in Cloudflare Workers:
- ❌ Not supported - requires
node:fs/promisesfor file reading - Code would need modification to accept strings instead of file paths
Workarounds: For S/MIME in Cloudflare Workers, consider:
- Using email providers that handle signing/encryption server-side
- Running signing/encryption in a separate Node.js service
- Modifying the code to accept certificate/key content as strings