A recovery message is part of an authentication ceremony, not a marketing campaign. The choice changes once an e-commerce team must prove what the system decided without storing the secret it sent. Use a short-lived email link as the normal path, allow an SMS one-time code only after an explicit eligibility check, and record the state transitions for both.
TL;DR: Give every signup-verification or password-recovery attempt one opaque correlation ID. Store hashed challenges, purpose, expiry, policy version, and channel transitions. Keep message delivery events separate from security outcomes. SMS can restore access when email is unavailable, but it changes the threat surface and the compliance evidence required, so escalation should be a policy decision rather than an automatic retry.
Replace the delivery timeline with an evidence timeline
The tempting model is linear: create token, send email, see an open, declare success. It is easy to draw and wrong at the exact point an auditor or incident responder starts asking questions. Apple Mail Privacy Protection can download remote content in the background, so an image request cannot establish that a person read a message. Delivery telemetry describes the message path. It does not authenticate the recipient.
Use this diagram-in-words instead:
request accepted -> policy evaluated -> challenge issued -> channel accepted -> challenge presented -> challenge consumed -> credentials changed
Only the last three security events move the account toward recovery. Provider events such as accepted, deferred, bounced, or delivered belong on a parallel delivery timeline joined by the correlation ID.
No pixel can do that.
| Before | After |
|---|---|
| Email open means the link worked | Valid token consumption means the link worked |
| SMS starts when email is slow | SMS starts only when policy permits escalation |
| Logs contain an address, phone, or token | Logs contain opaque IDs and stable reason codes |
| Region selects a flow | Purpose, policy, and recorded user state select a flow |
| A delivery callback mutates the account | The recovery service alone mutates the account |
That final boundary matters. A delivery callback may update delivery status, but it must never reset a password or mark an address verified. A person proves control by presenting the challenge through the application, where expiry, intended purpose, account state, and prior consumption can be checked together.
The same model covers the verification link sent during store signup. Give it a distinct purpose, such as signup_verify, rather than allowing a signup token into a password_reset transition. A shared transport is fine. Shared authority is not.
What should the recovery service record?
Record enough to reconstruct a decision, not enough to replay it. For each attempt, retain an opaque attempt ID, account ID, challenge purpose, creation and expiry times, policy version, selected channel, reason code, and outcome. Store a one-way digest of each random token or code. Keep raw credentials, full message bodies, email addresses, phone numbers, link query strings, and submitted challenges out of logs.
A compact event vocabulary works well: recovery.requested, challenge.issued, delivery.updated, challenge.rejected, challenge.consumed, and credential.changed. Rejections should use bounded reasons such as expired, mismatch, rate_limited, or already_used. Return the same public response for existing and unknown accounts so the endpoint does not become an account directory.
Retention needs an explicit decision. Keep security evidence only as long as its documented purpose requires, with access control and deletion behavior defined beside the schema.
For EU users, do not stamp consent=true onto every transactional recovery event. GDPR Article 7 describes conditions that apply when processing is based on consent, including the controller's ability to demonstrate consent and the person's ability to withdraw it. It does not make consent the automatic basis for every account-security message. Have counsel map purpose and legal basis, then encode the resulting policy version. US requirements can also vary by message purpose and jurisdiction. US and EU are routing inputs, not legal opinions.
A copyable TypeScript boundary
This example separates policy, storage, delivery, and audit emission. The token travels to the user, while only its HMAC digest enters storage. HMAC also avoids treating a fast hash of a six-digit code as adequate protection against offline enumeration. Keep the secret key in managed secret storage and plan its rotation.
The example generates a 16-byte random attempt identifier and a separate 32-byte random link token. They serve different jobs: the identifier connects evidence and is not secret, while the token proves control and must never enter telemetry. The six-digit SMS code has a far smaller input space, which is why its HMAC storage, attempt limits, short policy-defined lifetime, and atomic consumption must work together. Copying the email-token handling while dropping the attempt controls would preserve the shape of the code but lose the security argument.
import { createHmac, randomBytes, randomInt } from "node:crypto";
type Purpose = "signup_verify" | "password_reset";
type Channel = "email_link" | "sms_otp";
type Policy = {
version: string;
allowSmsFallback: boolean;
linkTtlMs: number;
otpTtlMs: number;
};
type Challenge = {
attemptId: string;
accountId: string;
purpose: Purpose;
channel: Channel;
digest: string;
expiresAt: number;
policyVersion: string;
};
interface Store {
insert(value: Challenge): Promise<void>;
consumeIfValid(input: {
attemptId: string;
purpose: Purpose;
digest: string;
now: number;
}): Promise<boolean>;
}
interface Delivery {
sendEmailLink(input: { attemptId: string; accountId: string; token: string }): Promise<void>;
sendSmsCode(input: { attemptId: string; accountId: string; code: string }): Promise<void>;
}
interface Audit {
emit(event: Record<string, string>): Promise<void>;
}
export class RecoveryService {
constructor(
private store: Store,
private delivery: Delivery,
private audit: Audit,
private hmacKey: Buffer,
private policy: Policy,
) {}
private digest(value: string): string {
return createHmac("sha256", this.hmacKey).update(value).digest("hex");
}
async issueEmailLink(accountId: string, purpose: Purpose): Promise<string> {
const attemptId = randomBytes(16).toString("hex");
const token = randomBytes(32).toString("base64url");
await this.store.insert({
attemptId, accountId, purpose, channel: "email_link",
digest: this.digest(token),
expiresAt: Date.now() + this.policy.linkTtlMs,
policyVersion: this.policy.version,
});
await this.delivery.sendEmailLink({ attemptId, accountId, token });
await this.audit.emit({
name: "challenge.issued", attemptId, accountId, purpose,
channel: "email_link", policyVersion: this.policy.version,
});
return attemptId;
}
async issueSmsCode(
attemptId: string,
accountId: string,
purpose: Purpose,
userRequested: boolean,
): Promise<void> {
if (!this.policy.allowSmsFallback || !userRequested) return;
const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
await this.store.insert({
attemptId, accountId, purpose, channel: "sms_otp",
digest: this.digest(code),
expiresAt: Date.now() + this.policy.otpTtlMs,
policyVersion: this.policy.version,
});
await this.delivery.sendSmsCode({ attemptId, accountId, code });
}
consume(attemptId: string, purpose: Purpose, submitted: string): Promise<boolean> {
return this.store.consumeIfValid({
attemptId, purpose, digest: this.digest(submitted), now: Date.now(),
});
}
}
consumeIfValid must check digest, purpose, expiry, and prior use while marking the challenge consumed in one atomic operation. The database transaction owns single use.
The snippet deliberately sets no universal time-to-live, retry count, or escalation delay. Those are risk-policy inputs, not constants that transfer safely between stores. Configure chosen values, test their boundaries, and attach the policy version to every event.
Should password reset use an email link or SMS OTP fallback?
Not because an email pixel stayed quiet. That signal is unreliable for this purpose, and automatic escalation can create two active challenges. Offer fallback after explicit user action and a policy check covering a previously verified phone, rate limits, account eligibility, and permitted message purpose.
Make it deliberate.
| Question | Email link | SMS one-time code |
|---|---|---|
| What proves control? | Presentation of the random link token | Presentation of the code |
| Operational ambiguity | Accepted or opened mail may produce no user action | Accepted SMS may produce no user action |
| Secret exposure to avoid | Query strings in logs, analytics, and support tools | Codes in logs, previews, and support tools |
| Evidence to retain | Issue, expiry, consumption, and policy events | Eligibility, issue, attempts, expiry, consumption, and policy events |
There is a real trade-off. Email-first recovery is not suitable when mailbox access is the thing the user has lost. SMS fallback can help then, yet it depends on a previously verified number and adds abuse, privacy, and account-lifecycle concerns. SMS is also a poor fallback when number ownership is stale or unavailable. In those cases, route the person to a separately governed support recovery process rather than weakening the challenge.
Normalize transport callbacks into a narrow internal status model and keep the original event ID for deduplication. Verify callback authenticity according to the adapter's documented mechanism. Process callbacks idempotently because retries and reordering must not alter challenge security state.
Alert on user harm or loss of control: a sharp rise in rejection reasons, consumption latency approaching expiry, repeated fallback denials, callback verification failures, or a widening gap between issued and consumed challenges by channel. Raw send volume alone is rarely the best page.
Can one flow serve both US and EU storefronts?
One state machine can. One hard-coded policy should not. Keep the security invariants global: generic public responses, random challenges, one-way stored digests, purpose binding, atomic consumption, bounded attempts, and no secrets in telemetry. Put message purpose, retention, fallback eligibility, and required evidence behind reviewed policy configuration.
Test the evidence path as product code. Cover expired tokens, purpose mismatch, double consumption, unknown accounts, denied escalation, callback duplication, and redaction. In staging, use controlled destinations and assert that logs never contain the generated secret. A production synthetic account can exercise issuance and expiry without completing a customer's recovery; label those events so exports can distinguish them.
The signup scenario now fits cleanly. A shopper gets a link-based verification step when creating an account. Later, recovery reuses delivery adapters and observability vocabulary, but issues a different purpose and evaluates a recovery-specific policy. Operations stay consistent without allowing one ceremony's proof to authorize another.
The decision rule: prefer the least expansive channel that can complete the ceremony, and add fallback only when its identity, abuse, privacy, and evidence boundaries are explicit. A useful audit trail is not a transcript of every packet. It is a defensible account of each security decision.
References
- Apple, "Use Mail Privacy Protection on iPhone": https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
- GDPR, Article 7, "Conditions for consent": https://gdpr-info.eu/art-7-gdpr/
- OWASP, "Forgot Password Cheat Sheet": https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- OWASP, "Logging Cheat Sheet": https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- Node.js, "Crypto" API documentation: https://nodejs.org/api/crypto.html
Top comments (0)