Logistics Password Reset Email Backend: Express Rate Limits and Compliance Evidence
TL;DR: A simple Express password reset email backend should put one small service between the public form and the send API. Enforce rate limits before creating a token, store only a digest of that token, and append an au
TL;DR: A simple Express password reset email backend should put one small service between the public form and the send API. Enforce rate limits before creating a token, store only a digest of that token, and append an audit log event before dispatch. For a logistics portal, turn those events into a generated evidence report that compliance staff can request as an email attachment. This is the least complex design that separates account recovery from delivery while leaving a useful trail.
Do not make the email provider your security boundary. Its delivery quota cannot express the controls that matter here: a limit per source, a limit per account, single-use tokens, expiry, and a record of why a message was or was not queued. Keep those rules in the application and hide delivery behind a narrow interface. That choice also keeps token cost at zero for the security-critical path; an LLM has no job deciding whether a recovery request is valid.
How should an Express Node.js backend send a password reset email?
A request enters through the public recovery form. The service normalizes the address, checks two rate-limit keys, and always gives the caller the same bland response. If the account exists and both limits permit the attempt, it creates a random token, stores a hash with an expiry, writes a recovery_requested event, and asks an email adapter to deliver the link. A separate internal job can query those append-only events, generate a CSV evidence report, and send that report as an attachment to an authorized compliance mailbox.
The important split is between customer mail and compliance mail. The first contains a short-lived secret-bearing link. The second contains aggregated operational evidence and must never contain raw reset tokens. Mixing the two data shapes is an easy way to leak a credential into a report that will be retained much longer than the recovery window.
This is intentionally boring.
Reports outlive tokens.
Build the boundary before choosing a delivery service
The example below uses Express and in-memory implementations so the control flow is visible. Those stores are process-local and therefore suitable for a runnable demonstration, not a multi-instance deployment. Replace them with shared stores that can perform atomic increments and single-use token consumption before scaling beyond one process. The transport remains generic; its implementation may call any delivery API or an internal relay.
import express from "express";
import { createHash, randomBytes, randomUUID } from "node:crypto";
type SendMessage = {
to: string;
subject: string;
text: string;
attachments?: Array<{ filename: string; content: Buffer; contentType: string }>;
};
interface MailTransport {
send(message: SendMessage): Promise<{ messageId: string }>;
}
type AuditEvent = {
id: string;
occurredAt: string;
kind: "recovery_requested" | "recovery_suppressed" | "mail_queued";
accountRef: string;
sourceRef: string;
reason?: "source_limit" | "account_limit";
deliveryRef?: string;
};
const app = express();
app.use(express.json({ limit: "16kb" }));
const audit: AuditEvent[] = [];
const counters = new Map<string, { count: number; resetsAt: number }>();
const tokens = new Map<string, { accountId: string; expiresAt: number; used: boolean }>();
// Demo data stands in for the logistics portal's account directory.
const accounts = new Map([
["[email protected]", { id: "acct_dispatch_17" }]
]);
const mail: MailTransport = {
async send(): Promise<{ messageId: string }> {
return { messageId: randomUUID() };
}
};
function opaque(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function allow(key: string, maximum: number, windowMs: number): boolean {
const now = Date.now();
const current = counters.get(key);
if (!current || current.resetsAt <= now) {
counters.set(key, { count: 1, resetsAt: now + windowMs });
return true;
}
if (current.count >= maximum) return false;
current.count += 1;
return true;
}
app.post("/account-recovery", async (req, res) => {
const email = typeof req.body?.email === "string"
? req.body.email.trim().toLowerCase()
: "";
const source = req.ip ?? "unknown";
const accountRef = opaque(email);
const sourceRef = opaque(source);
const sourceAllowed = allow(`source:${sourceRef}`, 20, 15 * 60_000);
const accountAllowed = allow(`account:${accountRef}`, 5, 60 * 60_000);
const account = accounts.get(email);
if (!sourceAllowed || !accountAllowed) {
audit.push({
id: randomUUID(),
occurredAt: new Date().toISOString(),
kind: "recovery_suppressed",
accountRef,
sourceRef,
reason: sourceAllowed ? "account_limit" : "source_limit"
});
} else if (account) {
const rawToken = randomBytes(32).toString("base64url");
const tokenDigest = opaque(rawToken);
tokens.set(tokenDigest, {
accountId: account.id,
expiresAt: Date.now() + 15 * 60_000,
used: false
});
audit.push({
id: randomUUID(),
occurredAt: new Date().toISOString(),
kind: "recovery_requested",
accountRef,
sourceRef
});
const result = await mail.send({
to: email,
subject: "Reset your logistics portal password",
text: `Open accounts.example.test/reset?token=${encodeURIComponent(rawToken)}`
});
audit.push({
id: randomUUID(),
occurredAt: new Date().toISOString(),
kind: "mail_queued",
accountRef,
sourceRef,
deliveryRef: result.messageId
});
}
res.status(202).json({
message: "If the account exists, recovery instructions will be sent."
});
});
app.listen(3000);
The numbers in this sample are policy inputs, not universal constants: 20 attempts per source in 15 minutes, five per account in one hour, and a 15-minute token lifetime. Pick production values from the portal's risk model and observed legitimate traffic, then record the policy version alongside each decision. A depot behind shared network address translation may put many operators behind one source address, so a source-only limit can block a shift at the worst moment. An account-only limit, meanwhile, lets one origin spray many addresses. The two dimensions cover different abuse shapes.
There is another deliberate asymmetry. Unknown accounts do not trigger mail, yet callers receive the same status and message as known accounts. Keep response timing under review as well; equal text alone is weak protection if one path consistently returns much faster. The audit uses digests for email and source references so routine evidence does not become a second directory of personal data. A keyed pseudonym may be preferable when ordinary address hashes could be guessed from a known staff list. That decision belongs in the threat model.
Timing leaks remain.
Make audit evidence answerable, not merely abundant
A compliance reviewer usually needs to reconstruct a decision: when did it occur, which policy applied, what was the outcome, and which delivery record corresponds to it? A pile of application logs does not automatically answer those questions. Define an event schema, make event identifiers unique, use UTC timestamps, and keep secrets out. Record the delivery system's message identifier, but do not treat it as proof that a human received or read the message.
For this flow, a useful evidence row has an event ID, timestamp, event kind, pseudonymous account and source references, rate-limit policy version, decision reason, and delivery reference. It does not need the recovery URL, raw token, password, or message body. Data minimization is practical here: fewer sensitive fields mean fewer places that require tight access and deletion handling.
The report generator should read a bounded time range and escape cells before producing CSV. Spreadsheet formula injection is relevant whenever a cell can begin with formula characters, even if the current identifiers look controlled. Prefixing risky values is a small defensive step. Send the report only after an authorization check outside the public recovery route.
type EvidenceRow = AuditEvent & { policyVersion: string };
function csvCell(value: string | undefined): string {
const raw = value ?? "";
const formulaSafe = /^[=+\-@]/.test(raw) ? `'${raw}` : raw;
return `"${formulaSafe.replaceAll('"', '""')}"`;
}
function makeEvidenceCsv(rows: EvidenceRow[]): Buffer {
const header = [
"event_id", "occurred_at", "kind", "account_ref",
"source_ref", "reason", "delivery_ref", "policy_version"
];
const lines = [header.map(csvCell).join(",")];
for (const row of rows) {
lines.push([
row.id, row.occurredAt, row.kind, row.accountRef, row.sourceRef,
row.reason, row.deliveryRef, row.policyVersion
].map(csvCell).join(","));
}
return Buffer.from(lines.join("\n"), "utf8");
}
async function sendEvidenceReport(
transport: MailTransport,
recipient: string,
rows: EvidenceRow[],
rangeLabel: string
): Promise<string> {
const result = await transport.send({
to: recipient,
subject: `Credential recovery evidence: ${rangeLabel}`,
text: "The requested logistics portal evidence report is attached.",
attachments: [{
filename: "credential-recovery-evidence.csv",
content: makeEvidenceCsv(rows),
contentType: "text/csv; charset=utf-8"
}]
});
return result.messageId;
}
This attachment path is also a clean test of the transport boundary. Customer recovery messages and staff reports use the same send capability, but their authorization, retention, templates, and recipient rules stay separate. The interface accepts bytes and a media type rather than assuming a filesystem path, which works for a generated report and avoids a temporary-file lifecycle.
Where do retries, limitations, and trade-offs belong?
Do not hold an HTTP request open while a delivery system retries. In production, persist the recovery request and an outbox item in the same transactional boundary, then let a worker dispatch mail. A stable idempotency key on that item prevents an application retry from becoming two recovery messages. The public response can remain generic even when downstream delivery is delayed.
Be precise about state. mail_queued means the transport accepted the request; it does not mean delivered, placed in an inbox, opened, or acted upon. If the transport provides later status events, validate their authenticity, deduplicate them, and append them as new evidence rather than rewriting history. Your dashboard should distinguish application suppression, queue backlog, transport rejection, and later delivery outcomes because each calls for a different response.
Retries need a ceiling and backoff. A permanent address rejection should not churn forever, while a transient failure may justify another attempt. Store retry count and next-attempt time with the outbox item. Never mint a new reset token merely because dispatch is retried; the queued message should retain the original request's identity and expiry. Once the token expires, suppress stale delivery and require a fresh recovery request.
The main limitation is scope: this design controls a recovery request and records its dispatch, but it cannot prove that a person received the message. There are concrete trade-offs too. An in-process counter is not suitable for multiple server instances, while a shared atomic store adds an operational dependency. An email acceptance identifier is not delivery proof. CSV is cheap to generate and inspect, but it may be the wrong evidence format when a reviewer needs signatures or immutable retention. Use authenticated delivery events when available, and choose a signed archival format if the compliance policy demands those properties. Those are system requirements, not reasons to pick one mail vendor.
Queues can lie.
Cost matters, but it is not the design center. Count accepted requests, suppressed attempts, dispatch attempts, attachment bytes, and status events. Those units reveal abuse and capacity pressure without tying the architecture to a changing per-message price. They also show whether evidence-report generation is quietly becoming the heavier workload.
Which tests catch the expensive mistakes?
Start with invariants. A known and unknown address must receive the same public status and response shape. The sixth account attempt inside the sample window must not create a token or enqueue mail. The twenty-first source attempt must be suppressed. Only a digest may enter token storage, and the audit stream must never contain the raw token or recovery URL.
Then test concurrency. Two simultaneous requests at the limit must not both pass because they read the same old counter. Two consumers must not send the same outbox item. Two attempts to redeem one token must produce one success. These tests will fail against casual read-then-write database code, which is exactly why atomic operations or transactions matter.
Exercise the report separately with commas, quotes, line breaks, and leading =, +, -, or @ characters. Confirm its time range is half-open and unambiguous, for example start <= occurredAt < end, so adjacent reports neither omit nor duplicate boundary events. Also verify that a user authorized to request a report cannot choose an arbitrary recipient.
A staging delivery test should assert the adapter contract and capture the provider's returned identifier. Keep it out of unit tests. Most behavior here, including suppression and audit shape, can be tested with a fake transport and a fixed clock, which is faster and avoids sending real mail during every build.
Before release, walk through the flow with security, operations, and whoever owns compliance evidence. Confirm that the two rate limits use shared atomic storage, the proxy configuration gives the application a trustworthy source address, token consumption is single-use, and logs redact query strings containing recovery links. Check that the outbox has a bounded retry policy and an alert on oldest-item age, not merely queue length.
Next, request a real evidence report through the internal authorization path. Open the attachment, inspect its date boundaries and encoding, trace one row back to the dispatch record, and verify that no token or full address appears. Confirm who may receive it and how long both the report and underlying events are retained. Finally, rehearse key rotation for pseudonymous account references; without a written overlap plan, rotation can make one reporting period impossible to correlate.
Ship when those answers are concrete. The simple backend is the one whose decisions can be reconstructed without exposing the credential it was meant to protect.
References
- Resend documentation: https://resend.com/docs/introduction
- CTIA messaging interoperability and compliance best practices: https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.