Encrypting subscriber addresses is useful, but it does not make a newsletter anonymous or end-to-end encrypted. Your sending worker must recover the address, and your mail provider receives it. A sound signup flow combines a clear subscription choice, confirmation, encrypted storage and a suppression check immediately before every send.
This tutorial defines that flow and an authenticated-encryption helper for Node.js. It is not a legal compliance checklist or a complete mailing platform. Use synthetic addresses locally, keep keys outside the database, and review applicable consent and retention requirements before collecting real subscribers.
Model the subscription lifecycle
Store a subscriber ID, encrypted address, lookup digest, status, consent-text version, request time, confirmation time and suppression time. Pending subscribers must not enter campaign audiences. A confirmation token should be random, single-use and expiring, with only its digest stored.
Return the same public response for existing and new addresses to reduce enumeration. Rate-limit requests and confirmation emails so an attacker cannot use the form to harass someone. A fresh signup for a suppressed address should require a deliberate new confirmation; it must not automatically clear the suppression record.
Encrypt with integrity, not just obscurity
AES-GCM authenticates the ciphertext as well as encrypting it. Generate a fresh nonce for every encryption under the same key. Bind the subscriber ID as additional authenticated data so moving one ciphertext into another subscriber row fails authentication.
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
export function encryptAddress(address, subscriberId, key) {
if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error("32-byte key required");
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
cipher.setAAD(Buffer.from(subscriberId, "utf8"));
const body = Buffer.concat([cipher.update(address, "utf8"), cipher.final()]);
return { version: 1, nonce: nonce.toString("base64"),
body: body.toString("base64"), tag: cipher.getAuthTag().toString("base64") };
}
export function decryptAddress(record, subscriberId, key) {
if (record.version !== 1) throw new Error("Unsupported encryption version");
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(record.nonce, "base64"));
decipher.setAAD(Buffer.from(subscriberId, "utf8"));
decipher.setAuthTag(Buffer.from(record.tag, "base64"));
return Buffer.concat([decipher.update(Buffer.from(record.body, "base64")),
decipher.final()]).toString("utf8");
}The helper uses Node's authenticated encryption APIs. Generate keys with a cryptographic random source and store a key ID alongside each record for rotation. Keep old keys available until migration and restore tests succeed. Losing the key loses the addresses; storing it beside ciphertext in the same database weakens separation.
For duplicate lookup, compute an HMAC of your documented normalised address using a separate lookup key. A plain hash is vulnerable to guessing likely addresses. Normalisation must be consistent across signup, unsubscribe and sending; do not casually strip plus tags or dots because those are provider-specific identity rules.
Make unsubscribe durable and easy
Use a long, random unsubscribe token scoped to one subscriber, not an email address in the URL. An ordinary GET should display a confirmation page without changing state, because email security scanners may open links. The deliberate confirmation POST sets suppression idempotently. It must not require signing in.
For mailbox-provider one-click unsubscribe, implement the separate POST contract and headers described by RFC 8058, including its signing requirements. Do not label an ordinary GET link “one-click compliant”. Keep a visible body link too.
At dispatch time, the worker reloads current subscription state and skips suppressed recipients before decrypting. A campaign audience snapshot is not enough: someone may unsubscribe after the campaign is queued. A send already handed to the provider cannot necessarily be recalled, so make that timing limitation explicit.
Test the lifecycle
Test encryption round-trip, changed ciphertext, wrong key, wrong subscriber ID and unsupported version. Authentication failures must not fall back to plaintext. Inspect logs to ensure neither decrypted addresses nor tokens appear.
Test pending signup, confirmation twice, expired confirmation, unsubscribe twice, and signup after suppression. Queue a campaign, suppress the subscriber, then run the worker: the mock sender must receive no call. These are local invariants; they do not prove real mailbox delivery or legal compliance.
Troubleshooting and next steps
If suppressed users still receive queued campaigns, move the check to dispatch time. If duplicate signup records appear, enforce uniqueness on the lookup digest and handle concurrent requests. If decryption fails after deployment, check key IDs and encoding before altering records.
For a signup, compose and suppression workflow with clear storage boundaries, explore CodeKits branded email systems.
