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 pkijs and asn1js packages installed (required for S/MIME operations)
npm install pkijs asn1js

When 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_compat flag and key content as strings (no file paths). Uses unenv polyfills for node:crypto compatibility (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/promises for file reading and node:crypto for 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/promises for file reading. Code would need modification to accept strings instead of file paths.

Encryption Algorithms:

  • aes-256-cbc (default) - AES-256 in CBC mode
  • aes-192-cbc - AES-192 in CBC mode
  • aes-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

  1. 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.

  2. 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.

  3. Certificate Management: Ensure that certificates used for signing and encryption are valid and trusted by the recipients.

  4. Private Key Security: Always handle private keys securely. Avoid hardcoding them in your codebase; use environment variables or secure vaults instead.

  5. Encrypted Private Keys:

    • Node.js: Full support for encrypted private keys (PBES2/PBKDF2). Simply provide the passphrase option. 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 unenv polyfills. Provide the passphrase option just like in Node.js (unenv).

Platform Compatibility

FeatureNode.jsBunDenoCloudflare 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_compat flag using unenv polyfills
  • S/MIME Signing/Encryption: ❌ Not supported - requires node:fs/promises for file reading

DKIM in Cloudflare Workers:

  • ✅ All required node:crypto functions are available via unenv polyfills (unenv)
  • createHash, createSign, and createPrivateKey are all supported
  • ✅ Works with key content as strings (no file paths needed)
  • ✅ Encrypted keys are supported (via createPrivateKey from 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/promises for 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
Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues