Forgot-password backend in Node and Postgres that doesn't leak accounts
Hashed single-use reset tokens in Postgres, an identical response for unknown addresses, a database-backed cooldown, an audit row per attempt, and the send call.
A safe forgot-password endpoint is four moving parts: a hashed single-use token row in Postgres, a response that reads the same whether or not the address is registered, a cooldown keyed on the identifier instead of the session, and an audit row written on every attempt. Only the last step talks to a mail provider — here that’s Infrai’s POST /v1/email/send.
Most tutorials get the send right and the token wrong, so the schema comes first.
Three tables, and why the token never lands in the database
Generate 32 bytes of randomness, put the raw value in the link, and store only its SHA-256 digest. A leaked database backup then contains no usable reset links.
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON password_reset_tokens (user_id, created_at DESC);
CREATE TABLE password_reset_audit (
id BIGSERIAL PRIMARY KEY,
email_norm TEXT NOT NULL,
ip INET,
outcome TEXT NOT NULL,
message_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON password_reset_audit (email_norm, created_at DESC);
CREATE INDEX ON password_reset_audit (ip, created_at DESC);
outcome is a small closed set — sent, cooldown, unknown_address, suppressed, send_failed. Recording unknown_address matters: it’s the only place the fact that somebody probed a non-existent account survives, because the HTTP response deliberately won’t say so.
Thirty minutes is a reasonable expires_at. Longer and the link outlives the user’s attention; shorter and slow corporate mail queues start eating real requests.
Answer the same way for an address you’ve never seen
Enumeration protection is one rule with three parts, and all three have to hold: same status code, same response body, same rough latency. The OWASP forgot-password guidance is blunt about it — the endpoint should return a generic acknowledgement to every well-formed request.
const GENERIC = {
ok: true,
message: "If that address has an account, a reset link is on its way.",
};
The latency part is the one people forget. If the “user exists” branch hashes a token and makes an HTTPS call while the “no such user” branch returns instantly, you’ve rebuilt enumeration out of a stopwatch. Two workable fixes: enqueue the send and return immediately in both branches, or run a dummy hash on the miss path. We use the queue shape below because it also stops a slow provider from holding an API worker open.
The cooldown lives in Postgres, not in a Map
An in-memory counter resets when the pod restarts and doesn’t exist at all on the other three replicas. A single query over the audit table gives you both an identifier cooldown and a per-IP ceiling:
SELECT
count(*) FILTER (WHERE email_norm = $1 AND created_at > now() - interval '60 seconds') AS recent_for_email,
count(*) FILTER (WHERE ip = $2 AND created_at > now() - interval '1 hour') AS recent_for_ip
FROM password_reset_audit
WHERE created_at > now() - interval '1 hour';
One request per address per 60 seconds, and something like 20 per IP per hour, stops both the accidental double-click and the scripted sweep. When the cooldown trips you still return GENERIC — a 429 on the reset endpoint is itself an enumeration oracle if it only fires for real accounts.
The send
No from field in this call. On a standard account, a from on a domain you haven’t registered comes back as HTTP 402 PRO_REQUIRED (custom sender domains are a Pro feature), and the shared sender works immediately:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "casey@example.com",
"subject": "Reset your password",
"html": "<p><a href=\"https://app.example.com/reset?t=RAW_TOKEN\">Choose a new password</a>. The link stops working in 30 minutes.</p>"
}'
{
"ok": true,
"data": {
"message_id": "msg_2ZhTtleGakhMuXd68qzTrugF",
"mode": "default_vendor",
"from_used": "noreply+a1f9@send.infrai.cc",
"accepted_recipients": ["casey@example.com"],
"suppressed_recipients": []
}
}
Read suppressed_recipients before you write outcome = 'sent'. An address that previously hard-bounced or complained is on the account suppression list, and the call succeeds without delivering anything — which for a reset link is a support ticket in about four minutes.
The handler
Node 22, express and pg, no email SDK — the API is plain REST:
import express from "express";
import crypto from "node:crypto";
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const API_KEY = process.env.INFRAI_API_KEY;
if (!API_KEY) throw new Error("INFRAI_API_KEY is not set");
const GENERIC = { ok: true, message: "If that address has an account, a reset link is on its way." };
const app = express();
app.use(express.json());
async function audit(email, ip, outcome, messageId = null) {
await pool.query(
"INSERT INTO password_reset_audit (email_norm, ip, outcome, message_id) VALUES ($1,$2,$3,$4)",
[email, ip, outcome, messageId],
);
}
async function deliver(email, rawToken) {
const res = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: { authorization: `Bearer ${API_KEY}`, "content-type": "application/json" },
body: JSON.stringify({
to: email,
subject: "Reset your password",
html: `<p><a href="https://app.example.com/reset?t=${rawToken}">Choose a new password</a>. The link stops working in 30 minutes.</p>`,
}),
});
const payload = await res.json();
if (!res.ok || !payload.ok) throw new Error(payload?.error?.code ?? `HTTP ${res.status}`);
return payload.data;
}
app.post("/auth/forgot-password", async (req, res) => {
const email = String(req.body?.email ?? "").trim().toLowerCase();
const ip = req.ip;
if (!email.includes("@")) return res.status(200).json(GENERIC);
const { rows: [limits] } = await pool.query(
`SELECT count(*) FILTER (WHERE email_norm = $1 AND created_at > now() - interval '60 seconds') AS per_email,
count(*) FILTER (WHERE ip = $2 AND created_at > now() - interval '1 hour') AS per_ip
FROM password_reset_audit WHERE created_at > now() - interval '1 hour'`,
[email, ip],
);
if (Number(limits.per_email) > 0 || Number(limits.per_ip) >= 20) {
await audit(email, ip, "cooldown");
return res.status(200).json(GENERIC);
}
res.status(200).json(GENERIC);
try {
const { rows: [user] } = await pool.query("SELECT id FROM users WHERE email_norm = $1", [email]);
if (!user) return void audit(email, ip, "unknown_address");
const raw = crypto.randomBytes(32).toString("base64url");
const hash = crypto.createHash("sha256").update(raw).digest();
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1,$2, now() + interval '30 minutes')",
[user.id, hash],
);
const sent = await deliver(email, raw);
const blocked = sent.suppressed_recipients?.length > 0;
await audit(email, ip, blocked ? "suppressed" : "sent", sent.message_id);
} catch (err) {
await audit(email, ip, "send_failed");
console.error("reset send failed", err);
}
});
app.listen(3000);
Redemption is the other half and it’s four lines of SQL: hash the incoming token, UPDATE … SET used_at = now() WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() RETURNING user_id, and treat zero rows as an expired link. The UNIQUE constraint plus used_at IS NULL makes replay impossible even under concurrent clicks.
Checking what actually happened to a link
message_id is the join key between your audit table and the provider’s timeline. The message record gives you a state:
curl -sS "https://api.infrai.cc/v1/email/get/msg_2ZhTtleGakhMuXd68qzTrugF" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
For the full sequence, GET /v1/email/event/list needs the message_id as a query parameter — call it without one and you get a 400 telling you so:
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_2ZhTtleGakhMuXd68qzTrugF" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "type": "sent", "at": "2026-07-26T00:30:02.301347Z", "recipient": "casey@example.com" },
{ "type": "queued", "at": "2026-07-26T00:30:02.284184Z", "recipient": "casey@example.com" }
],
"next_cursor": null,
"count": 2
}
}
When a user swears the mail never arrived, that timeline plus your outcome column answers it in one query instead of a vendor dashboard hunt.
What the flow costs
Sending is the only billable step: $0.000115 per email, verified 2026-07-26 and marked approximate because the vendor underneath can change. Lookups — the message record, the event timeline, the suppression list — are free and rate-limited. A new account starts with $2 in credit, roughly 17,000 resets, which is more than most products send in a year. Rates on this platform move down rather than up and discount runs happen, so check today’s figure rather than trusting this line:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
for (const c of JSON.parse(s).capabilities)
if (c.id === "email.send") console.log(c.method, c.path, JSON.stringify(c.billing));
})'
Limitations, and when to pick someone else
Three caveats worth knowing before you commit. Custom sender domains are Pro-only, so on a standard account every reset goes out from the shared send.infrai.cc sender — fine for an internal tool, not what you want on a consumer product where the From line is part of the trust signal. Delivery status is polled, not pushed; there’s no inbound webhook, so a fast reset flow means a small poller rather than a callback. And this is a send-and-track API, not a marketing suite: no drip sequences, no visual editor.
| What you need | Better pick | Why |
|---|---|---|
| Only transactional mail, obsessively tuned | Postmark | Separate reset and broadcast streams, per-message inbound webhooks |
| Highest volume at the lowest unit price | Amazon SES | Pure metered, cheapest at scale, and you operate the reputation side |
| Reset mail plus the rest of the backend | Infrai | One key also covers storage, queues, cron, SMS and error capture |
If email is the only external service your app will ever call, a specialist like Postmark or Resend is a defensible choice and you should take it. The argument for consolidating shows up on the second question — the reset flow needs an audit trail, an SMS fallback for the accounts that lost inbox access, and a scheduled job to purge expired tokens, and all three are already reachable with the same key and land on one bill.