DEV Community

Kaelvyn47
Kaelvyn47

Posted on

Checkout Passwordless Access: Coordinating SMS OTP, Email Fallback, and Receipt Templates

Short answer: Keep the receipt workflow and the login workflow separate, but make them consume the same verified customer-contact record and the same application-owned template versions; after payment settles, an idempotent worker sends the receipt, while an Express.js challenge state machine handles SMS OTP and explicit email fallback with only one valid code.

That is the decision. The important caveat is terminological: SMS and email as alternative delivery channels are passwordless single-factor authentication, not 2FA. Calling the fallback a second factor does not make it one. A genuine two-factor flow must require two distinct factor types rather than accept either possession channel.

This boundary matters in e-commerce because payment settlement, receipt delivery, and account access fail independently. Coupling them turns a delayed message into a delayed order acknowledgement, or turns a login retry into a duplicate receipt. Don't do that.

Decision and failure boundaries

The application owns the state machine and all message templates. Delivery adapters receive already-rendered content plus a destination; they do not decide which template to use, when to fall back, or whether a challenge remains valid. The payment-settled consumer similarly renders a versioned receipt from immutable order facts and submits it with an idempotency key derived from the settlement event. Authentication is never on that critical payment path.

Four invariants define the architecture:

  1. A challenge has one opaque identifier, one purpose, one hashed code, one expiry, one attempt counter, and at most one successful consumption.
  2. Switching from SMS to email changes the delivery channel and invalidates the previous code. It does not create two simultaneously valid secrets.
  3. The browser receives generic challenge responses, so account existence and channel availability are not disclosed.
  4. A settled payment emits one logical receipt request. Retries reuse its idempotency key and pinned template version.

The failure boundaries follow those invariants. A delivery timeout may allow the user to request fallback, but it cannot roll back payment settlement. An authentication rate limit may reject another verification attempt, but it cannot suppress the already-committed receipt. A template rendering failure belongs before a delivery adapter is called and should place the job in an operator-visible terminal state rather than silently selecting unrelated copy.

There is a subtle cost benefit here, although cost is not the primary argument. When policy lives in one state machine, the event vocabulary stays bounded: challenge_created, delivery_requested, fallback_requested, verification_failed, challenge_consumed, and receipt_requested cover the useful transitions. Provider-specific callbacks can be normalized at the adapter boundary instead of multiplying dashboard series.

How should Express.js passwordless sign-in handle SMS OTP and email fallback?

Express should expose a small command-oriented surface and keep challenge transitions atomic in its persistence layer. Start with a generic request that accepts an account identifier and purpose. The server looks up verified destinations, creates the challenge, stores only a keyed digest of the code, selects SMS under policy, and returns the same response shape even when the identifier is unknown. The response can be 202 Accepted because delivery is asynchronous; it must not promise that a handset received anything.

curl --request POST 'https://shop.example/auth/challenges' \
  --header 'Content-Type: application/json' \
  --data '{"identifier":"buyer@example.net","purpose":"receipt_access"}'
Enter fullscreen mode Exit fullscreen mode

The fallback endpoint is an explicit user action after the UI's waiting period. It must lock the challenge row, confirm that the challenge is unexpired and unconsumed, rotate the code, invalidate the SMS code, increment a bounded delivery counter, and render the email variant from the same semantic template version. Return 202 again. A 429 Too Many Requests response is appropriate when the application-defined request budget is exhausted, with retry guidance that does not reveal whether an account exists.

curl --request POST 'https://shop.example/auth/challenges/ch_7F3K/fallback' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: fallback-ch_7F3K' \
  --data '{"channel":"email"}'
Enter fullscreen mode Exit fullscreen mode

Verification is a compare-and-consume transaction. The transaction checks the keyed digest, purpose, expiry, attempt budget, and consumed timestamp together; a successful comparison writes the consumption timestamp before a session is issued. A failed comparison increments the attempt counter without logging the submitted code. This is where apparently tidy controller code often hides a race: a read followed by a later write can let two concurrent requests redeem one OTP. The datastore operation, not an in-process flag, has to serialize that transition.

Consider the exact interleaving. Request A reads consumed_at = null and finds a matching digest. Before A writes, request B reads the same row and reaches the same conclusion. If each request creates a session and only then marks the row consumed, both sessions are valid even though every individual line of controller code looks reasonable. Put the conditional transition in one transaction: update the row only where the identifier matches, consumed_at is null, the expiry is still in the future, and the attempt budget remains; then issue a session only when exactly one row changed. A mismatch increments the attempt count through an equally constrained update. The same discipline applies during fallback: rotating the digest and changing the channel must be one transition, so a verification request cannot slip between those writes and accept the SMS code after email delivery has been requested. This example needs concurrency tests with two database connections, not two sequential calls in a unit test, because the invariant concerns an interleaving that sequential execution cannot expose.

One code survives.

curl --request POST 'https://shop.example/auth/challenges/ch_7F3K/verify' \
  --header 'Content-Type: application/json' \
  --header 'Origin: https://shop.example' \
  --data '{"code":"'"${OTP_CODE}"'","purpose":"receipt_access"}'
Enter fullscreen mode Exit fullscreen mode

Set the session cookie with Secure, HttpOnly, and an appropriate SameSite policy. Rate limits need several scopes: destination, account, network source, challenge, and a broader system budget. No single scope is sufficient. Destination-only limits can be distributed across many accounts, while network-only limits punish users behind shared gateways. Exact thresholds depend on traffic distribution and abuse evidence; I'm not sure a universal number exists, and a load test cannot substitute for production fraud signals.

SMS also imposes a content constraint that belongs in template tests. GSM-7 messages have different single-message and concatenated-segment limits from UCS-2 messages, so a non-GSM character can change segment count. Keep the authentication message terse, assert its encoding and segment count in CI, and never put sensitive order details in it. The email variant may be richer, but its code, purpose, and expiry semantics must remain identical.

Template ownership at payment settlement

Template ownership is an architecture choice, not a copywriting preference. The order service knows the receipt schema and the authentication service knows challenge semantics. Keeping versioned source templates beside those contracts makes review, localization tests, and rollback part of the normal deployment process. A delivery provider still owns transport concerns such as accepted payload shape and delivery status; it should not become the source of truth for business wording or state transitions.

Option Change control Runtime dependency Best fit Main limitation
Application-owned templates Code review and release Renderer plus channel adapters Regulated copy, coordinated SMS/email semantics, reproducible receipts Copy changes follow the application release process
Provider-owned templates Provider console or API Provider template identifier and stored remote state Operations teams that must change copy independently Drift is harder to detect across channels and environments
Hybrid: application source, synchronized remote copy Code review plus synchronization Local source and remote template state Channels that require pre-registered templates Deployment needs a reconciliation step and version mapping

For a payment-settled receipt, application ownership wins because the rendered artifact should be reproducible from order facts and a pinned template version. Store the version identifier with the receipt request, not the full rendered body in high-volume logs. If support needs an exact reconstruction, it can render from the retained order record and versioned template under access control.

The catch is organizational latency. Application-owned templates are not suitable when a legally authorized communications team must publish urgent copy without an engineering deployment. In that case, use provider-owned templates or a controlled content system, but export version history, require approval, and bind each receipt event to the remote template revision used. The valid use case is real; the loss of local reproducibility must be managed rather than ignored.

Critical path and telemetry budget

The payment handler should commit business state and an outbox record in one database transaction. A worker reads the outbox, loads immutable order facts, renders the pinned receipt template, and calls a channel adapter. The adapter records a normalized delivery reference. This transactional-outbox shape avoids a dangerous gap between committing payment and enqueueing the receipt, while keeping transport latency outside the request that settles the order.

Authentication uses a separate outbox and worker pool. That isolation prevents a login burst from consuming all receipt-delivery capacity. It also makes service-level objectives intelligible: receipt request age, challenge delivery request age, and verification latency describe different customer outcomes and should not be averaged into one pleasant but useless number.

Payment stays settled.

Count cardinality before adding a label. A metric such as auth_challenge_total{channel,outcome,purpose} has a bounded cross-product. Adding customer_id, challenge_id, order_id, phone number, or provider message identifier creates an unbounded series set and leaks identifiers into a system optimized for aggregation. Those values belong in access-controlled traces or structured audit records, and even there they should be minimized or tokenized according to the investigation need.

Retention math makes the trade-off concrete. Let E be daily events, B the average stored bytes per event after indexing overhead, R the retention days, and C the number of stored copies. The approximate footprint is E x B x R x C. This is a planning identity, not a benchmark. At a hypothetical 10 million events per day, 700 stored bytes, 30 days, and two copies, the result is 420 GB. Doubling retention doubles that footprint; adding a high-cardinality field can also increase index cost in ways this simple estimate does not capture.

Keep all security-relevant state transitions, but sample successful diagnostic traces after aggregation. Failed verifications, rate-limit decisions, fallback transitions, template-version changes, and receipt terminal outcomes deserve complete audit coverage with narrowly defined retention. Successful request spans are better candidates for probabilistic sampling, provided counters remain unsampled. This split preserves incident evidence without paying to retain every routine hop.

Short logs help.

Never log the OTP, rendered receipt, raw email address, phone number, session token, or provider credential. Record a stable internal event name, coarse outcome, template version, channel, latency bucket, and a restricted correlation token when investigation requires it. If a field has no named query, owner, and retention period, omit it.

Rejected option and its valid use case

The rejected design is a provider-controlled workflow in which an SMS service owns the initial template, an email service owns fallback timing and copy, and the checkout application merely starts the sequence. It is attractive because the first demo is small. It also splits the authentication state across administrative domains, makes simultaneous-code invalidation difficult to prove, and forces receipt and login telemetry into provider-specific event models.

Still, it has a valid use case: a low-risk campaign or notification sequence whose state has no authorization consequence and whose operators need direct control over timing and copy. Stick with that managed workflow when business users own the entire lifecycle and the application does not need to prove atomic code consumption. Do not use it to blur alternative delivery channels into 2FA or to put payment settlement behind messaging availability.

For the checkout system described here, the decision remains application-owned templates, separate outboxes, one active challenge secret, explicit fallback, and bounded observability dimensions. Those properties are testable. Brand preference isn't.

References

Further reading

Top comments (0)