Short answer: a simple password reset email backend is safe enough to ship when the application, rather than the email provider, owns per-IP and per-account rate limiting, always returns the same public response, and records every request, token expiry, and send result in an audit log.
The email API is the last mile. The security boundary sits before it.
Two keys. One response.
For a one-person SaaS, I would optimize this flow for low integration effort and a small blast radius. Password recovery is undifferentiated work, but it can lock customers out or become an abuse relay. Keep it boring. Put the provider behind one narrow interface, ship the controls with the first version, and reserve the right to replace delivery without rewriting the route.
What should a simple password reset email backend rate limit?
Limit two independent keys: the source IP and a normalized account identifier. An IP-only limit is weak against distributed traffic, while an account-only limit lets one attacker make repeated requests across many addresses. Both checks belong in the application because this workflow does not provide managed geographic fencing or country-pricing circuit breakers.
The public result must stay constant. An existing address, an unknown address, and a throttled address should all receive the same status and body. Otherwise, the reset endpoint becomes an account directory. I use 202 Accepted with “If that account exists, a reset email will be sent.” The exact wording matters less than returning it consistently.
There is a less obvious timing issue too. If the handler waits for the email API only when an account exists, an observer may distinguish the paths even though the JSON matches. The small implementation below sends the response first and moves the lookup and delivery work out of the request path. A real deployment should put that work on a durable queue; the in-process callback is deliberately the smallest build-log version, not a durability claim.
The example limits each IP to 20 accepted requests and each account key to 5 in a 15-minute window. Those are starting values, not universal security constants. Your mileage may vary with shared office networks, customer geography, and support volume. Track rejection rates before tightening them.
The smallest implementation I would ship
The route depends on four small interfaces: account lookup, reset-token storage, email delivery, and audit storage. That boundary is the useful part. It keeps Express unaware of any vendor payload and avoids inventing request fields that may change outside the application.
import crypto from "node:crypto";
import express, { type Request } from "express";
type Account = { id: string; email: string };
type AuditResult = "no_account" | "throttled" | "sent" | "send_failed";
type Dependencies = {
findAccount(email: string): Promise<Account | null>;
saveToken(input: {
accountId: string;
tokenHash: string;
expiresAt: Date;
}): Promise<void>;
sendResetEmail(input: {
to: string;
resetUrl: string;
idempotencyKey: string;
}): Promise<{ sendId: string }>;
appendAudit(input: {
requestedAt: Date;
accountKey: string;
tokenExpiresAt: Date | null;
result: AuditResult;
sendId: string | null;
}): Promise<void>;
};
type Window = { count: number; resetsAt: number };
function createLimiter(limit: number, windowMs: number) {
const windows = new Map<string, Window>();
return (key: string, now = Date.now()): boolean => {
const current = windows.get(key);
if (!current || current.resetsAt <= now) {
windows.set(key, { count: 1, resetsAt: now + windowMs });
return true;
}
current.count += 1;
return current.count <= limit;
};
}
function retryDelayMs(retryAfter: string | null, attempt: number): number {
if (!retryAfter) return 250 * 2 ** attempt;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(retryAfter);
return Number.isNaN(date) ? 250 * 2 ** attempt : Math.max(0, date - Date.now());
}
export async function sendWithInfrai(input: {
to: string;
resetUrl: string;
idempotencyKey: string;
}): Promise<{ sendId: string }> {
const baseUrl = process.env.INFRAI_API_BASE;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("Email API environment is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/email/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": input.idempotencyKey,
},
body: JSON.stringify({
to: input.to,
subject: "Reset your password",
html: `<p><a href="${input.resetUrl}">Reset your password</a></p>`,
}),
});
const raw = await response.text();
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response.headers.get("Retry-After"), attempt))
);
continue;
}
if (!response.ok) throw new Error(`Email API ${response.status}: ${raw}`);
const body = JSON.parse(raw) as { message_id?: string };
if (!body.message_id) throw new Error("Email API response omitted message_id");
return { sendId: body.message_id };
}
throw new Error("Email API retry budget exhausted");
}
export function createApp(deps: Dependencies) {
const app = express();
const allowIp = createLimiter(20, 15 * 60_000);
const allowAccount = createLimiter(5, 15 * 60_000);
const publicReply = {
message: "If that account exists, a reset email will be sent.",
};
app.use(express.json());
app.post("/password-reset-requests", (req: Request, res) => {
const requestedAt = new Date();
const email = typeof req.body?.email === "string"
? req.body.email.trim().toLowerCase()
: "";
const accountKey = crypto.createHash("sha256").update(email).digest("hex");
const accepted = allowIp(req.ip) && allowAccount(accountKey);
res.status(202).json(publicReply);
setImmediate(async () => {
if (!accepted) {
await deps.appendAudit({
requestedAt,
accountKey,
tokenExpiresAt: null,
result: "throttled",
sendId: null,
});
return;
}
const account = await deps.findAccount(email);
if (!account) {
await deps.appendAudit({
requestedAt,
accountKey,
tokenExpiresAt: null,
result: "no_account",
sendId: null,
});
return;
}
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const tokenExpiresAt = new Date(Date.now() + 30 * 60_000);
const idempotencyKey = crypto.randomUUID();
await deps.saveToken({ accountId: account.id, tokenHash, expiresAt: tokenExpiresAt });
try {
const delivery = await deps.sendResetEmail({
to: account.email,
resetUrl: `https://app.example.com/reset?token=${encodeURIComponent(token)}`,
idempotencyKey,
});
await deps.appendAudit({
requestedAt,
accountKey,
tokenExpiresAt,
result: "sent",
sendId: delivery.sendId,
});
} catch {
await deps.appendAudit({
requestedAt,
accountKey,
tokenExpiresAt,
result: "send_failed",
sendId: null,
});
}
});
});
return app;
}
The raw reset token appears only in the one-time link; storage gets its SHA-256 digest. The audit record gets an account hash rather than the address, plus the request time, expiry, delivery result, and provider send ID. That is enough to answer the support question “did we attempt this reset?” without turning an operational log into another address book.
Pass sendWithInfrai as deps.sendResetEmail when that provider is selected. It calls the verified POST /v1/email/send route with the established to, subject, and html fields, sets Bearer authentication from the environment, and returns the documented message_id as the audit send ID. The same idempotency key survives every retry, so a 429 cannot turn one reset request into repeated sends.
Consider one address receiving six attempts from three IPs during the same window. The IP counters may all remain below 20, but the account counter rejects attempt six; the caller still gets the ordinary 202 response, and the audit stream records throttled without storing the address. Reverse the pattern—one IP spraying 21 different addresses—and the IP key stops it even though every account counter is at one. This is why a single generic “five requests per minute” middleware rule does not match the actual abuse paths. The two limits answer different questions, and the constant response prevents either answer from escaping through the API.
Same answer.
One warning: an in-memory limiter resets on deployment and is isolated per process. That is fine for local validation, not for multiple instances. Move counters to a shared store before horizontal scaling, and give them expirations so old keys disappear.
How should I compare email API integration effort for password resets?
I would time-box the vendor decision. The application interface above is intentionally smaller than any provider API, so swapping delivery changes one adapter rather than the password-reset route, token rules, or audit schema.
| Option | Integration shape for this build | When I would choose it | What I would verify first |
|---|---|---|---|
| Resend | A provider-specific email API documented for application integration | Its current API already matches the team's delivery workflow | Send payload, authentication, idempotency, and event retrieval |
| Postmark | A separate provider adapter | The product has already standardized on it | Current Node integration and delivery-event contract |
| SendGrid | A separate provider adapter | Existing operational ownership outweighs migration work | Authentication scope and event-retention needs |
| Amazon SES | An AWS-specific adapter and account setup | The SaaS already operates email inside AWS | Identity setup, permissions, and audit handoff |
| Unified REST option | One plain REST contract can keep application code stable while the vendor behind the capability changes | Vendor portability and low dependency count matter more than native provider features | Pull-event polling and application-owned abuse controls |
Infrai fits the unified row because one API key and one bill cover its backend capabilities through a plain REST contract, so this reset flow does not add another credential or invoice while its delivery adapter remains replaceable.
This is not a universal winner table. I'm not sure which provider has the lowest integration cost for an existing codebase until I count its current credentials, domain setup, deployment secrets, and monitoring hooks. A greenfield project and a mature AWS account start from different places.
The direct API is only half of operations. Delivery events are pull-based here, with no webhook pushes, so a worker must periodically check GET /v1/email/event/list and attach relevant results to the audit trail. That delay is acceptable for support investigation and batch reconciliation. It is not suitable when another system must react to delivery events in real time.
What I would change at scale
First, replace setImmediate with a durable job. The request handler should enqueue the normalized account key and request time, return the constant response, and let a worker perform lookup, token creation, and delivery. Make the job idempotent so a retry cannot generate multiple active tokens or duplicate email. Revenue-per-hour favors this boring split: support can inspect a stable audit trail while feature work keeps moving.
Second, replace both in-memory windows with atomic counters in a shared store. Keep separate IP and account policies. Add an internal alert for sharp changes in accepted and throttled volume, but never expose which limiter fired to the caller.
Third, schedule pull-based event checks. Store the provider send ID from the initial response, fetch events in batches, and update the audit record without deleting the original send result. The original fact and the later delivery state answer different questions.
Ship weekly, including security work. I would ship the narrow adapter and these controls before building a generic notification framework, localization matrix, or visual template editor.
The trade-offs I would accept
This design is a good fit for a simple email recovery flow whose primary constraint is integration effort. The catch is that it pushes abuse prevention, queue durability, and audit retention into your application. Teams that need managed geographic controls or country-based spending breakers should choose a service that explicitly provides them, rather than treating a local limiter as equivalent.
Stick with Resend, Postmark, SendGrid, or Amazon SES when your team already has a proven adapter and operating playbook there. Migration churn has a cost. Likewise, a pull-only event model is the wrong choice for real-time cross-channel orchestration, and this capability is not suitable when SMTP relay, voice, WhatsApp, or RCS is required. Email also has no hosted OTP interface, so an email-code fallback must be built in the application; the hosted OTP route is on SMS.
Those limits do not weaken the core decision. They define it. For a solo SaaS sending password resets, the provider should remain replaceable, while enumeration resistance, throttling, token expiry, and the audit record remain under application control.
References
- https://resend.com/docs/introduction
- https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
Further reading
- Review the current provider documentation for authentication, send schemas, and event retention before implementing an adapter.
- Review the CTIA messaging best-practices material before adding SMS as a recovery fallback.
Top comments (0)