DEV Community

NyxenL29
NyxenL29

Posted on

2026 Password Reset Controls: Auditable Email and SMS Backup Across US/EU Shops

Use email as the default password reset channel, add SMS backup only after a recorded policy decision, and treat the audit log rather than either provider's callback as the compliance record. For a US/EU e-commerce account, this keeps the practical recovery path understandable: one reset intent, one expiring token, controlled channel escalation, and an evidence trail that never confuses “accepted for delivery” with “read by a person.”

Short answer: email-only is the safer baseline when the shop has no verified mobile number or SMS consent basis; SMS backup is suitable when the number was verified earlier, the legal classification is approved, and the delivery SLO justifies a second channel.

The cheap-looking part is sending a message. The expensive part is proving which policy ran, suppressing duplicate sends during retries, and explaining an account event months later without retaining the reset secret. That's the system boundary I would put in the design review.

What should a practical password reset email and SMS backup policy record?

Start with evidence semantics. “Queued,” “provider accepted,” “delivered,” and “user completed reset” are different events; collapsing them into a boolean called sent creates an audit record that says very little. A mail server accepting a request doesn't prove inbox placement, and a handset delivery signal doesn't prove that the account owner saw the text. The useful compliance record is therefore a signed or access-controlled sequence of decisions and observations: the reset intent identifier, policy version, destination reference, channel, template version, attempt number, event time, and normalized outcome. Store a hash or opaque reference for the destination where operations staff don't need the raw address or phone number.

Don't put the reset token in that log.

For an e-commerce shop, bind every attempt to one intent and make completion terminal. If email is late and the system escalates to SMS, either link may arrive first; the first valid completion consumes the intent, while the other link becomes useless. That rule matters more than channel ordering because retries, delayed callbacks, and impatient users routinely produce overlapping work even when every component behaves correctly.

An illustrative state model is small enough to review:

State Meaning Permitted next action
created Intent recorded; no dispatch yet Queue primary email
email_accepted Email transport accepted attempt 1 Wait, retry, or evaluate escalation
sms_accepted Approved backup transport accepted Wait for completion
completed Token consumed exactly once Revoke all remaining links
expired Recovery window ended Require a new intent

The table is a protocol, not a claim about legal sufficiency. US/EU compliance is not a single configuration flag, and I'm not sure a generic article can settle the purpose, consent, retention, or notice classification for a particular storefront; counsel must map those questions to the actual message and jurisdictions. Engineering can still make that decision inspectable by storing the policy version and approval reference used at dispatch time.

Build one intent, an outbox, and an append-only event stream

The safe implementation begins in the same database transaction that creates the reset intent. Insert an outbox item with a deterministic idempotency key, commit, and let a worker claim it. The worker renders a versioned template, calls a transport through a narrow interface, then appends the normalized result. A timeout is an unknown outcome, not permission to manufacture a new logical attempt; the same key must survive the retry so the transport adapter can deduplicate where supported and the application can reject a duplicate locally regardless.

Here is the core boundary in Go. It deliberately omits vendor endpoints and keeps legal policy outside the transport adapter.

package recovery

import (
    "context"
    "errors"
    "time"
)

type Channel string

const (
    Email Channel = "email"
    SMS   Channel = "sms"
)

type Dispatch struct {
    IntentID      string
    Channel       Channel
    DestinationID string
    TemplateVer   string
    PolicyVer     string
    Attempt       int
    IdempotencyKey string
}

type Receipt struct {
    TransportRef string
    AcceptedAt   time.Time
}

type Transport interface {
    Send(ctx context.Context, d Dispatch, rendered string) (Receipt, error)
}

type EvidenceStore interface {
    WasAccepted(ctx context.Context, key string) (bool, error)
    AppendAccepted(ctx context.Context, d Dispatch, r Receipt) error
}

func Deliver(ctx context.Context, tx Transport, log EvidenceStore, d Dispatch, rendered string) error {
    accepted, err := log.WasAccepted(ctx, d.IdempotencyKey)
    if err != nil {
        return err
    }
    if accepted {
        return nil
    }

    receipt, err := tx.Send(ctx, d, rendered)
    if err != nil {
        return err
    }
    if receipt.TransportRef == "" {
        return errors.New("transport accepted without a reference")
    }
    return log.AppendAccepted(ctx, d, receipt)
}
Enter fullscreen mode Exit fullscreen mode

Rendering belongs before dispatch, with the exact template version recorded beside the attempt. Mustache's variables are HTML-escaped by default, while triple braces or & produce unescaped output; for an email template, keep user-controlled values on the escaped path and reserve unescaped sections for reviewed static markup. Render failures should stop the attempt before a transport call, since an audit event that says a malformed notice was sent is worse than an explicit pre-dispatch rejection.

Capacity planning is less glamorous but decisive. Model peak reset intents per minute, not daily averages; multiply by the maximum attempts allowed for each channel, then reserve worker and queue headroom for a storefront-wide credential-stuffing burst. If 2,000 intents arrive in a ten-minute interval and policy permits one email plus one SMS, the queue must absorb up to 4,000 dispatch records even though most healthy flows should never use the backup. Those are scenario inputs, not benchmark results. Use the shop's measured arrival distribution before setting concurrency.

When should email-only escalate to an SMS fallback?

Escalation should be a policy evaluation, not a timer with a phone number attached. Inputs can include whether the mobile destination was verified before the reset request, whether the approved jurisdictional policy permits this message class, whether an email acceptance event exists, how much time remains before token expiry, recent attempt counts, and abuse signals. The output should be email_only, sms_allowed, or manual_recovery; recording the inputs and policy version makes the result reproducible without putting sensitive content in the event stream.

No verified number? Stop.

An SMS backup is also not suitable when shared phones create unacceptable account-takeover exposure, when the team cannot maintain consent and suppression records, or when mobile delivery events cannot be reconciled into the same evidence vocabulary as email. Stick with email-only when its observed completion latency meets the recovery SLO. Move high-risk or inaccessible cases to a separately reviewed manual recovery path rather than weakening token rules merely to improve a delivery chart.

For commercial content in the United States, the FTC's CAN-SPAM guide explains sender, subject, address, opt-out, and monitoring duties. A password recovery message may have a different primary purpose, but adding promotions can change the analysis; keep recovery copy narrowly transactional and have counsel classify it. In the EU, don't infer permission from the existence of a number. The practical engineering rule is to make the jurisdiction and approved policy explicit inputs, because a transport adapter cannot decide legal purpose from a template name.

The buy-versus-build decision follows from ownership, not feature count:

Component Buy when Build when On-call catch
Message transport Carrier and mailbox integration would distract the team A regulated constraint rules out managed delivery External status still needs normalization
Policy engine Rules change often and need governed approvals Rules are few, reviewed, and versioned in code A bad rule can multiply every dispatch
Evidence store Managed retention and access controls match requirements Data location or query model is unusually strict Immutability and deletion duties can conflict
Template renderer Non-engineers need controlled authoring Templates are small and ship with code Version drift breaks reconstruction

Don't select a service because its first-request price is attractive. Select only after a failure drill proves idempotency behavior, exportability of evidence, retention controls, and an exit plan; your mileage may vary because those constraints depend on traffic shape and jurisdiction, not a generic “cheapest” label.

Verify evidence before enabling the backup channel

Test the state machine before testing deliverability. In a staging environment with non-production destinations, replay the same outbox item 20 times and assert that the evidence stream contains one accepted logical attempt. Deliver email after SMS, complete through each link in separate cases, and verify that the second completion is rejected. Advance the clock past expiry. Rotate the template version between queueing and dispatch, then confirm the worker uses the pinned version rather than whatever is current at send time.

Next, run controlled transport tests and inspect normalized events. The dashboard should separate intent creation, queue age, transport acceptance, confirmed delivery where a channel supplies it, completion, expiry, and manual recovery. A useful SLO might target the proportion of legitimate reset intents completed within a chosen window, while transport acceptance and queue age remain diagnostic service-level indicators; choose the actual threshold from customer risk and measured baselines, not from this example. Alert on queue-age burn and unexplained outcome rates, because raw send volume will happily look healthy during a retry storm.

Audit retrieval needs its own drill. Given an intent ID, an authorized reviewer should be able to reconstruct which policy chose email-only or SMS backup, which template version rendered, what each transport reported, and when the token became terminal, without retrieving the token itself. Then test retention expiry and access logging. Evidence that nobody can query under pressure is decorative storage.

Keep the canary narrow — one jurisdiction-policy combination, one storefront, and a capped percentage of eligible intents. Compare completion latency, duplicate-attempt suppression, queue age, support contacts, and manual-recovery rate against the email-only baseline. This isn't a contest to maximize SMS sends; a low escalation rate can be the correct result.

Roll back policy, not the audit trail

Rollback should disable new SMS evaluations through a versioned feature flag while leaving already recorded intents and evidence readable. Drain or cancel queued backup items according to their recorded state, preserve idempotency keys, and return new requests to email-only; never delete historical events to make the rollback look clean. If the release changed schemas, use expand-and-contract deployment so the previous worker can still read new records before traffic shifts.

The catch is that email-only remains dependent on mailbox access, while SMS backup adds phone-number lifecycle, abuse, legal, and operational concerns. Neither channel proves human receipt. The defensible design is the one whose limits are explicit, whose policy can be reversed quickly, and whose evidence shows exactly what the system decided and observed.

References

Top comments (0)