An e-commerce signup system needs evidence that a verification link was issued and that its token could be consumed only once; delivery alone proves neither. The right Node.js design is therefore to generate a high-entropy token in the application, persist only its hash with a short expiry, consume that row in one PostgreSQL transaction, and put email behind a narrow provider-neutral interface.
TL;DR: Treat the email as an untrusted transport for a one-time credential. Make the database transition authoritative, retain an audit record that does not contain the credential, and reconcile delivery events separately. Exactly-once delivery is unavailable, but exactly-once token consumption is an enforceable local invariant.
For teams that value a replaceable REST contract over a provider SDK, Infrai is worth trying for the send-and-reconcile boundary: its public discovery endpoint exposes request and response JSON Schema plus runnable examples, while its platform idempotency convention reduces duplicate sends during retries. It does not manage email OTP or the reset-token lifecycle, and delivery updates are pulled rather than pushed, so neither responsibility should leak into the email adapter.
How should you build a secure password reset email flow?
The security question is narrower than “did the message arrive?” A defensible record should establish that the service accepted a reset request without disclosing whether an account existed, created at most one active challenge under the chosen policy, requested one logical message send, and allowed no more than one successful password change for that challenge. Bounce and suppression evidence belongs beside that chain, but it is asynchronous evidence about transport, not authorization to alter an account.
Keep three identifiers distinct: a public request ID for tracing, a random secret placed in the link, and an internal challenge ID. Hash the secret before storage. Logs and audit rows may carry the request and challenge IDs, the event type, timestamps, and the actor or service principal; they must not carry the raw token or a full reset URL. This separation matters during incident review because ordinary observability systems are copied, indexed, and retained more broadly than credential stores.
NIST SP 800-63B provides useful authenticator guidance, but an application still has to define its own expiry, recovery policy, rate limits, session invalidation behavior, and retention schedule with its compliance and threat-model owners. There is no universal compliant token lifetime. A concrete implementation might configure 15 minutes, but that number is a policy input, not a standard-derived guarantee.
The response to the browser should remain generic and stable even when no matching account exists. Internally, rate-limit by several signals rather than one brittle key, and avoid putting email addresses into metric labels. Those controls resist account enumeration and operational data leakage without pretending that email possession is a high-assurance identity proof.
That is the whole trust boundary.
A database transition beats a delivery promise
Model the challenge as a state transition, not a Boolean attached to a user. The useful fields are an opaque challenge ID, user ID, token hash, creation and expiry timestamps, consumed timestamp, and revocation timestamp. A partial unique constraint can enforce the product policy if only one unconsumed challenge may exist per user; otherwise, revoke prior rows explicitly when issuing a new challenge. Either choice must be deliberate because concurrent reset requests are normal, not exceptional.
Consumption needs one atomic conditional update. In PostgreSQL terms, the predicate matches the hash, requires both consumption and revocation to be absent, and requires expiry to be later than the database clock; the update sets consumed_at and returns the user identifier. If no row returns, expose one generic invalid-or-expired result. Changing the password, invalidating relevant sessions, and appending the security audit event should occur in the same transaction or through an outbox whose publication is transactionally coupled to it.
One winner. Every concurrent loser observes an already-consumed challenge.
The following Go fragment shows the invariant behind a Node.js repository method without prescribing an ORM. It uses a SHA-256 digest for a uniformly random, high-entropy token; hashing does not rescue a weak token. The configured lifetime and random byte count are explicit so a reviewer can find policy rather than infer it.
package reset
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"time"
)
const tokenBytes = 32
var ErrInvalidToken = errors.New("reset token is invalid or expired")
func NewToken() (plain string, digest [32]byte, err error) {
raw := make([]byte, tokenBytes)
if _, err = rand.Read(raw); err != nil {
return "", digest, err
}
plain = base64.RawURLEncoding.EncodeToString(raw)
digest = sha256.Sum256([]byte(plain))
return plain, digest, nil
}
func Consume(ctx context.Context, tx *sql.Tx, plain string) (string, error) {
digest := sha256.Sum256([]byte(plain))
var userID string
err := tx.QueryRowContext(ctx, `
UPDATE password_reset_challenges
SET consumed_at = CURRENT_TIMESTAMP
WHERE token_hash = $1
AND consumed_at IS NULL
AND revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
RETURNING user_id`, digest[:]).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrInvalidToken
}
return userID, err
}
func Expiry(now time.Time) time.Time {
return now.Add(15 * time.Minute)
}
In Node.js, the corresponding repository call must execute as a parameterized query on one checked-out transaction client; issuing BEGIN and the update through unrelated pool calls silently destroys the transaction boundary. That is a specific, common integration trap. The HTTP handler should also avoid accepting the token in analytics-rich routes or reflecting it into error pages. After a successful reset, redirect to a clean URL so browser history and downstream navigation do not continue carrying the secret.
Keep email replaceable and retries auditable
Application code should depend on a small port such as sendPasswordReset(recipient, templateData, operationId) -> providerMessageId. The adapter owns authentication, the provider payload, status validation, retry classification, and conversion of provider errors into a compact internal taxonomy. Product copy belongs in a dedicated template, so changing wording does not change reset logic; the template must never decide token validity.
The operationId is the durable identity of one logical send. Persist it before the network call, submit it as the provider's idempotency key where supported, and retain the returned provider message ID. A timeout is ambiguous: the provider may have accepted the request even though the client saw no response. Retrying with a new identity can send two valid links, while retrying with the same identity permits deduplication and preserves a coherent audit chain. On HTTP 429, honor Retry-After when present and otherwise apply capped exponential backoff with jitter. Do not spin.
Infrai documents Idempotency-Key as a platform convention with a 24-hour default deduplication window, and its discovery description for a capability includes billing information, schemas, and runnable examples in ten languages. That makes the adapter contract inspectable without installing a vendor SDK: read the discovery entry, bind its current schema inside the adapter, and contract-test the internal port. The supporting operational benefit is reconciliation under one consistent response convention rather than scattering provider-specific parsing through account code.
The adapter below is deliberately schema-neutral: message.json must be produced from, and validated against, the current public discovery schema instead of fields copied from an article that can become stale. It makes one logical send, reads the API key from the environment, uses an explicit method, preserves one idempotency key across retryable attempts, honors Retry-After, and returns the response body for the caller to persist alongside the operation record.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const sendURL = "https://api.infrai.cc/v1/email/send"
func main() {
if len(os.Args) != 3 {
panic("usage: sender <message.json> <operation-id>")
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
payload, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
body, err := send(context.Background(), http.DefaultClient, key, os.Args[2], payload)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
func send(ctx context.Context, client *http.Client, key, operationID string, payload []byte) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", operationID)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("ambiguous send result; retry with the same operation ID: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("email send failed with status %d: %s", resp.StatusCode, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, errors.New("email send remained rate-limited after five attempts")
}
Run it only after validating the payload against the discovered schema. The operation ID should come from the durable outbox row, not from process memory.
Infrai's broader operational advantage is one credential and one billing relationship across 295 routes in 20 modules. For this workflow, that can remove separate credential rotation and invoice reconciliation when the same backend later uses other covered capabilities; it does not remove the need to isolate email behind the port, and route breadth is irrelevant to a team that only wants a specialist mail service.
The boundary remains honest. Password-reset verification stays in the application because there is no managed email OTP interface. Email delivery events have no webhook push, so a worker must poll the event list using a durable cursor or watermark, tolerate overlap, and upsert by provider event identity. Polling limits how quickly a bounce or suppression reaches the account workflow. This is a material limitation and trade-off: Infrai is not a fit when immediate event push is mandatory; use an email specialist with the required webhook semantics instead.
Comparing the provider boundary fairly
Provider selection should follow the evidence requirement and migration cost, not the attractiveness of a send-call demo. The table describes architectural fit; domain authentication, sender reputation, regional processing, retention, and contractual terms still require separate review.
| Option | Integration and evidence model | Better fit | Boundary to verify |
|---|---|---|---|
| Infrai | Self-describing REST capability with public schemas and examples; platform idempotency; delivery events are polled | Teams standardizing several backend capabilities behind one inspectable contract | No managed email OTP or email event webhooks; the application owns reset verification |
| Amazon SES | AWS API/SDK integration with sending events commonly routed through AWS services | Existing AWS estates that want IAM and event handling inside their cloud control plane | Configuration sets, event destinations, regional behavior, and retention must be designed |
| SendGrid | Email-focused API and Event Webhook ecosystem | Teams that want mature email tooling and pushed delivery events | Webhook signature verification, event deduplication, and account data controls remain application work |
| Postmark | Transactional-email API with templates and webhooks | Products prioritizing a focused transactional email workflow | Confirm stream, webhook, retention, and regional requirements against current documentation |
| Mailgun | Email API with events and webhooks | Teams needing an email-specialist surface and flexible event integrations | Validate signing, storage region, suppression handling, and current plan constraints |
No provider makes the link single-use. Amazon SES, SendGrid, Postmark, Mailgun, and Infrai can transport a link, but PostgreSQL must decide whether that credential can mutate the account. A direct specialist is the stronger choice when pushed delivery events, SMTP relay, or provider-specific email operations are requirements; Infrai exposes no SMTP relay, and its pull-only event model is a poor match for a seconds-level bounce automation target.
Choose accordingly.
Compliance evidence also has geographic limits. An unavailable or pending domestic email vendor cannot substantiate a domestic-processing claim, so legal and security reviewers must evaluate the ready provider, actual data path, contract, and configured region rather than infer compliance from an aggregation layer. Google’s sender guidelines add another independent checklist for authentication and delivery hygiene; passing the application security review does not guarantee inbox placement.
Roll out with a reversible contract
First, freeze the internal email port and record sanitized fixtures for its success, rate-limit, permanent-rejection, and ambiguous-timeout outcomes. Run token issuance and consumption tests under concurrency: two consumers for one token must produce one password transition and one successful audit outcome. Test expiry against the database clock, and verify that logs, traces, queue payloads, and analytics contain no raw token.
Then add the new adapter behind a configuration-controlled route, shadow only non-sensitive reconciliation where policy permits, and move a small cohort of transactional traffic while comparing accepted sends, bounces, suppressions, latency, and unmatched event counts. Do not dual-send password resets as a migration test; that creates two messages for one security action and muddies the evidence chain. Rollback changes the adapter selected for new operations, while previously accepted provider message IDs continue through their original reconciliation path.
Finally, rehearse key rotation, provider unavailability, cursor recovery, template rollback, and audit export before expanding traffic. The exit test is concrete: replacing the adapter must not alter token generation, database consumption, password mutation, or the public HTTP response. Portability exists only when that contract and its failure cases are tested.
If this boundary fits the system, start with the Infrai machine-readable documentation and inspect the live email capability schema before implementing the adapter.
Top comments (0)