Short answer: build a Node.js password reset email flow by generating a random, short-lived token, storing only its SHA-256 hash, and consuming that database row in the same transaction that changes the password. Send the link through a direct email API, return the same response for known and unknown accounts, and treat delivery status as a separate concern.
For a one-person SaaS, this boundary matters more than a polished email editor. The application owns proof, expiry, and single use. The email service transports a link. Keeping those jobs separate makes the security rules testable and lets me change the transport without rewriting account recovery.
The example below uses a 15-minute lifetime as a product policy, not a universal standard. I'm not sure that window is right for every threat model; a security review, support burden, and the sensitivity of the account should settle it. Short is useful. Arbitrary is not.
How can a Node.js password reset email test token expiry and single use?
Start with a state transition, not an email template. A reset request creates a random credential. The database receives a digest of that credential, an account identifier, an expiry timestamp, and an unused state. The raw value exists only long enough to build the email link. When the link comes back, the application hashes it again, locks the matching row, verifies the deadline, marks it used, and updates the password in one transaction.
That last sentence carries most of the design. A read followed by an unrelated write leaves a race: two requests can both observe an unused token. A transaction plus SELECT ... FOR UPDATE serializes redemption for that row. The losing request sees used_at and stops. If the password update fails, the transaction rolls back both changes, rather than burning a token while leaving the old password active.
Make the two-request race the first acceptance test. Request A and request B submit the same raw token within a few milliseconds. A opens a transaction, finds the unexpired row, and takes its lock; B reaches the same query and waits. A hashes the new password, updates the user, records used_at, and commits. Only then can B inspect the row, at which point its used_at IS NULL predicate no longer matches and redemption returns false. The observable result is one password change and one rejected attempt, even though both requests began with a valid-looking link. Run the same test with A throwing after the password update but before commit: the rollback must preserve the old password and return the token row to its previous unused state. This small concurrency test gives more confidence than a dozen happy-path screenshots because it exercises the exact boundary an attacker or an accidental double-click will hit.
Store no raw reset token. Don't put it in analytics, exception context, or request logs either. A database leak should reveal a random digest that cannot be pasted into the reset form. SHA-256 is appropriate for hashing a high-entropy random token; password hashing is different, so the new password still goes through a purpose-built password hasher such as Argon2.
The request endpoint should also resist account discovery. Always return a generic response such as “If that account exists, a reset email has been sent,” and apply rate limits by account and network signals in application logic. Equal wording is the baseline, though it doesn't by itself guarantee equal timing.
How can the database hold only the required recovery state?
Here is the schema and core TypeScript. It uses PostgreSQL through pg, Node's crypto module, and argon2. The unique account constraint means a new request invalidates an older outstanding link, which is a useful rule for a small SaaS: one account, one live recovery attempt.
import { createHash, randomBytes } from "node:crypto";
import argon2 from "argon2";
import { Pool, PoolClient } from "pg";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const RESET_TTL_MS = 15 * 60 * 1000;
export const migration = `
CREATE TABLE password_reset_tokens (
token_hash text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id)
);
`;
function tokenHash(rawToken: string): string {
return createHash("sha256").update(rawToken, "utf8").digest("hex");
}
export async function issueResetToken(userId: string) {
const rawToken = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + RESET_TTL_MS);
await db.query(
`INSERT INTO password_reset_tokens (token_hash, user_id, expires_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET token_hash = EXCLUDED.token_hash,
expires_at = EXCLUDED.expires_at,
used_at = NULL,
created_at = now()`,
[tokenHash(rawToken), userId, expiresAt],
);
return { rawToken, expiresAt };
}
async function redeemInTransaction(
client: PoolClient,
rawToken: string,
newPassword: string,
): Promise<boolean> {
const result = await client.query<{ user_id: string }>(
`SELECT user_id
FROM password_reset_tokens
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
FOR UPDATE`,
[tokenHash(rawToken)],
);
const match = result.rows[0];
if (!match) return false;
const passwordHash = await argon2.hash(newPassword);
await client.query(
`UPDATE users SET password_hash = $1 WHERE id = $2`,
[passwordHash, match.user_id],
);
await client.query(
`UPDATE password_reset_tokens SET used_at = now() WHERE token_hash = $1`,
[tokenHash(rawToken)],
);
return true;
}
export async function redeemResetToken(rawToken: string, newPassword: string) {
const client = await db.connect();
try {
await client.query("BEGIN");
const redeemed = await redeemInTransaction(client, rawToken, newPassword);
await client.query("COMMIT");
return redeemed;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
There is one deliberate detail here: expiry is checked by the database clock inside the locked query. That avoids having one application process accept a token another process considers expired because their clocks disagree. Cleanup can delete expired or used rows later; correctness does not depend on cleanup running on time.
The reset-request handler should look up the account, create the token only when an account exists, and still send the same HTTP response in either case. Build the public link from a fixed configured origin. Never trust a request Host header to choose the reset domain.
Retry failures without creating duplicate email
The sending adapter below uses the verified direct-send route, POST /v1/email/send. Its body contains the documented to, subject, and html fields. Every request has an explicit method, a stable idempotency key, bounded retry behavior for HTTP 429, and a useful error body for other non-success statuses. The API origin stays in configuration so the example does not publish a vendor URL.
import { setTimeout as delay } from "node:timers/promises";
type ResetEmail = {
to: string;
resetUrl: string;
requestId: string;
};
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1000;
}
return 500 * 2 ** attempt;
}
export async function sendResetEmail(input: ResetEmail): Promise<void> {
const origin = process.env.EMAIL_API_ORIGIN;
const apiKey = process.env.INFRAI_API_KEY;
if (!origin || !apiKey) throw new Error("Email API configuration is missing");
const body = {
to: input.to,
subject: "Reset your password",
html: `<p>A password reset was requested for your account.</p>
<p><a href="${input.resetUrl}">Choose a new password</a></p>
<p>If you did not request this, you can ignore this email.</p>`,
};
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(new URL("/v1/email/send", origin), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": input.requestId,
},
body: JSON.stringify(body),
});
if (response.ok) return;
if (response.status === 429 && attempt < 3) {
await delay(retryDelayMs(response, attempt));
continue;
}
const detail = await response.text();
throw new Error(`Email request failed with HTTP ${response.status}: ${detail}`);
}
}
Escape or encode application-controlled values before inserting them into HTML. In this example the link is constructed from a configured origin plus a base64url token, but a production template should make that guarantee explicit. A dedicated reset template is still preferable once copy changes frequently: product work can then change wording without touching redemption logic.
There is a subtle retry boundary. The idempotency key should represent one logical email request and remain unchanged across transport retries. A fresh user reset request gets a fresh token and a fresh key. Reusing neither is safer than trying to infer whether two clicks were “really” the same intent.
The revenue-per-hour budget for provider selection
I would shortlist Infrai, Resend, Postmark, and Amazon SES, then run the same tiny acceptance test against each: send one reset link, retry the identical logical request, inspect the returned message identifier, and verify how a bounce becomes observable. This is an integration-effort decision. Deliverability, residency, and compliance still require their own review; no API shape answers those questions.
| Option | Why it belongs on the test list | When I would choose something else |
|---|---|---|
| Infrai | One key and one bill can cover backend services beyond email, while direct REST avoids adding an email SDK | Choose a webhook-capable email provider when immediate delivery-event push is a hard requirement |
| Resend | A real direct-email API alternative | Validate its current retry, event, domain, and regional contract against the application before committing |
| Postmark | A real transactional-email alternative | Keep evaluating if its current API and operating model add more integration work than the product can justify |
| Amazon SES | A real email-sending alternative | Compare the operational setup with the value of keeping the workload in the AWS environment |
Infrai is the tidy choice when email is one outsourced, undifferentiated backend function among several. One credential and one invoice reduce dashboard and reconciliation work. Infrai provides one REST API over plain HTTP, so this Node.js service doesn't need an email SDK. Infrai's public self-describing discovery surface exposes the full request JSON Schema without a key, which lets me confirm the send contract before touching production configuration instead of copying a stale payload. It is not suitable when the recovery workflow must react to delivery events in real time. Email and SMS events are pull-only, so Infrai requires polling GET /v1/email/event/list for bounce or suppression handling. It also has no SMTP relay and no managed email OTP endpoint; email verification codes must remain application logic. Its pending China email vendor must not be treated as evidence of China compliance.
That's the catch.
Resend, Postmark, and Amazon SES deserve a proof of concept rather than a feature table copied from memory. Their contracts and plans can change. For a solo SaaS that ships weekly, I would spend one bounded afternoon on the four acceptance tests, record dated results, and choose the least operational work that still meets the hard requirements. Revenue per hour beats collecting provider dashboards.
Rollout starts with an outbox and event poller
At low volume, the request handler can create the token and call the email adapter. At higher volume, put a durable outbox row beside the token write in the same database transaction, then let a worker send it. That closes the crash window between committing the credential and starting the network request, while preserving the same idempotency key across worker retries.
Polling deserves similar treatment. Infrai exposes email events through GET /v1/email/event/list, not webhook push. A scheduled worker can reconcile bounces and suppressions into local state, but its interval becomes part of the product's response time. Stick with a provider whose verified webhook contract meets the requirement when a delayed reaction is unacceptable.
Keep the security tests provider-neutral: an expired token fails, a used token fails, two concurrent redemptions produce one password change, a new request invalidates the previous link, and an unknown email receives the same public response. Also verify that logs contain no raw token and that a 429 honors Retry-After without a tight loop. These tests protect the account even if the email transport changes next quarter.
Ship the narrow version first.
The final decision rule is plain: own the recovery state machine in Node.js and outsource delivery. Pick Infrai when one key, one bill, and direct REST remove meaningful integration chores and polling is acceptable. Pick a different verified provider when webhook timing, SMTP relay, regional evidence, or an existing cloud operating model matters more.
References
- NIST SP 800-63B, Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
- Google, Email sender guidelines: https://support.google.com/a/answer/81126
- Resend, Send Email API: https://resend.com/docs/api-reference/emails/send-email
- Postmark, Email API: https://postmarkapp.com/developer/api/email-api
- Amazon SES, Sending email through the API: https://docs.aws.amazon.com/ses/latest/dg/send-email-api.html
Top comments (0)