Node.js Login: How to Trace 3 SMS and Email OTP Decisions
Consider the page an e-commerce on-call should investigate when a login flow lets a user choose SMS or email: an authenticated request succeeds after the shopper's account-deletion transaction has committed. The evidence
Consider the page an e-commerce on-call should investigate when a login flow lets a user choose SMS or email: an authenticated request succeeds after the shopper's account-deletion transaction has committed. The evidence needs an account state version, a session version, and a request outcome. A green message-delivery dashboard cannot explain that request. This is a proposed incident drill, not a report of a production incident.
Short answer: let the shopper choose SMS or email from destinations already verified for that account, replace the outstanding challenge when they switch channels, and check account eligibility again when consuming the code and authorizing every session. For a Node.js login flow, those are three separate decisions: channel selection, proof consumption, and continued session authorization. Deleting the account must fence off all three.
Ask what page fired.
The implementation below is a runnable Go state-machine example; a Node.js handler would need to preserve the same transaction boundaries in its shared authentication store. Its purpose is to make the security behavior executable, including the uncomfortable case where a valid code arrives after deletion. Revoking access is one part of an account-erasure workflow. It doesn't by itself establish GDPR compliance or settle which commerce records must be retained.
How should a user choose SMS or email without escaping deletion checks?
Work backward from the accepted request. Was a session created after deletion, did an older session bypass its revocation check, or did a code issued before deletion get exchanged against stale account state? Those are different failures, even when the shopper sees the same login screen. A postmortem needs the ordering of the state changes, not a screenshot of successful SMS deliveries.
Record a challenge identifier, selected channel, account state version, result category, and correlation identifier at the authentication boundary. Keep codes and destinations out of these events. For the deletion drill, record the committed version change and whether subsequent authorization was rejected. Use identifiers with controlled retention for investigation; putting a shopper identifier in every metric label creates both an operational burden and another data-retention problem.
The earlier signal is a violation of the account-state invariant: a deleted account was allowed to issue a challenge, consume one, or authorize a session. A delayed message is a separate delivery signal. Confusing them produces a noisy page and leaves the security failure poorly specified.
The OWASP Authentication Cheat Sheet supports generic authentication responses, reauthentication for sensitive operations, and login throttling. Those controls matter here because a channel chooser can otherwise become an account-discovery interface, while repeated channel switching can become another way to request messages without completing authentication.
Choose the fallback policy before building the screen. In this example, SMS and email are alternative ways to satisfy one login proof; selecting both doesn't turn the flow into two-factor authentication. Only previously verified destinations are eligible. A request to add a different address or phone number belongs to a separately authenticated change process.
There is a trade-off. Replacing the challenge means a shopper who switches to email must ignore a late SMS. Keeping both codes valid would reduce that friction, but it also leaves more outstanding proofs to reason about during a deletion race. This design chooses one current challenge per account for the demonstration; a production flow can scope challenges to a login transaction, provided account deletion invalidates every transaction.
2. Implement the three decisions as one executable state model
Save this as main.go and run it with the Go toolchain. It uses only the standard library and prints pass/fail checks without sending a message or exposing a code. Every number here is an example policy, not a measured result or a universal requirement: a 6-digit code, a 5-minute lifetime, 5 verification attempts, and a 30-second interval between challenge issuances.
The map of eligible channels stands in for verified destinations loaded from account records. The returned code stands in for the payload delivered through a private sender; an HTTP handler must never include it in the login response. This deliberately small model has no HTTP server, durable database, message queue, or distributed coordination. Its mutex makes the ordering visible within one process. It cannot supply those guarantees across multiple Node.js workers.
package main
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"math/big"
"sync"
"time"
)
type challenge struct {
id, channel, code string
version uint64
expires time.Time
attempts int
}
type session struct {
version uint64
}
type account struct {
mu sync.Mutex
active bool
version uint64
eligible map[string]bool
pending *challenge
lastIssue time.Time
}
func (a *account) issue(channel string, now time.Time) (string, string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if !a.active || !a.eligible[channel] {
return "", "", fmt.Errorf("challenge unavailable")
}
if !a.lastIssue.IsZero() && now.Sub(a.lastIssue) < 30*time.Second {
return "", "", fmt.Errorf("challenge unavailable")
}
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", "", err
}
idBytes := make([]byte, 16)
if _, err := rand.Read(idBytes); err != nil {
return "", "", err
}
id := fmt.Sprintf("%x", idBytes)
code := fmt.Sprintf("%06d", n.Int64())
a.pending = &challenge{
id: id, channel: channel, code: code, version: a.version,
expires: now.Add(5*time.Minute),
}
a.lastIssue = now
return id, code, nil
}
func (a *account) consume(id, code string, now time.Time) (session, error) {
a.mu.Lock()
defer a.mu.Unlock()
c := a.pending
if !a.active || c == nil || c.id != id ||
c.version != a.version || !now.Before(c.expires) || c.attempts >= 5 {
return session{}, fmt.Errorf("proof rejected")
}
c.attempts++
if subtle.ConstantTimeCompare([]byte(c.code), []byte(code)) != 1 {
return session{}, fmt.Errorf("proof rejected")
}
a.pending = nil
return session{version: a.version}, nil
}
func (a *account) authorize(s session) bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.active && s.version == a.version
}
func (a *account) deleteAccount() {
a.mu.Lock()
defer a.mu.Unlock()
a.active = false
a.version++
a.pending = nil
}
func check(name string, ok bool) {
if !ok {
panic(name)
}
fmt.Println("PASS:", name)
}
func main() {
a := &account{
active: true, version: 1,
eligible: map[string]bool{"sms": true, "email": true},
}
now := time.Now()
smsID, smsCode, err := a.issue("sms", now)
check("SMS challenge issued", err == nil)
emailID, emailCode, err := a.issue("email", now.Add(31*time.Second))
check("email fallback issued", err == nil)
_, err = a.consume(smsID, smsCode, now.Add(32*time.Second))
check("replaced SMS challenge rejected", err != nil)
s, err := a.consume(emailID, emailCode, now.Add(33*time.Second))
check("email proof accepted", err == nil && a.authorize(s))
_, err = a.consume(emailID, emailCode, now.Add(34*time.Second))
check("email replay rejected", err != nil)
nextID, nextCode, err := a.issue("sms", now.Add(62*time.Second))
check("another login started", err == nil)
a.deleteAccount()
check("existing session revoked", !a.authorize(s))
_, err = a.consume(nextID, nextCode, now.Add(63*time.Second))
check("pending proof rejected after deletion", err != nil)
_, _, err = a.issue("email", now.Add(94*time.Second))
check("deleted account cannot restart login", err != nil)
}
The challenge identifier matters. Even if two randomly generated codes happen to match, a replaced SMS challenge cannot be consumed under its old identifier. The version matters for a different reason: session acceptance depends on current account state, so consuming a proof successfully doesn't grant indefinite access after deletion.
Treat session as an internal record in this demonstration. A version number isn't a bearer credential. Real sessions need unguessable credentials, secure transport and storage, and their own expiration policy; the example isolates the deletion check rather than presenting a complete session-token format. Likewise, storing the code directly is appropriate only for this in-memory exercise. A deployed verifier needs a protected representation, such as a keyed verifier with its secret stored separately, because the small code space makes an ordinary unkeyed hash cheap to enumerate.
For Node.js, keep channel selection server-side even if the browser supplies sms or email. Resolve the destination from the account record associated with an opaque login transaction. Don't accept an arbitrary destination alongside the channel. Before a user is identified safely, a chooser that reveals which destinations exist can defeat otherwise generic login errors.
Switch explicitly.
A delivery timeout alone is a poor reason to silently create a second valid proof, since a timeout doesn't establish that the first message will never arrive. The interface should explain that requesting the other channel replaces the previous code.
3. Move the instrumentation to the commit boundary
The next implementation step is replacing the process-local lock with a shared transaction or equivalent atomic operation. Challenge consumption must conditionally check the current account state, check expiry and attempts, consume the proof once, and create the session record. Deletion must change that same authoritative state and invalidate pending proofs. Two independent reads followed by two independent writes leave room for a race, however reassuring the individual handler tests look. Consider the bad interleaving explicitly: the verifier reads an active account, deletion commits, and the verifier creates a session using its earlier read. Moving the eligibility check into the session-creation transaction prevents that sequence only if the chosen isolation or conditional-write mechanism actually orders it against deletion. An application-level promise that both handlers usually finish quickly provides no such guarantee. This is the boundary the integration test must force, rather than hoping ordinary traffic exercises it.
Then instrument the outcome after the authoritative operation finishes. Record proof_rejected_deleted, session_rejected_version, and session_accepted as bounded event categories, with investigation identifiers in access-controlled logs. An event emitted before commit describes an intention; it cannot establish what the system accepted. Never log the code to make the dashboard easier to debug.
Test the boundary.
Message delivery proves nothing about session revocation.
The executable checks cover replacement, replay, an existing session, and an outstanding proof at deletion. Extend the integration suite with a wrong code, the fifth failed attempt, exact expiry, a resend during the cooldown, and simultaneous consumption. For the deletion race, control the scheduling so each operation wins once, then assert that no new authorization succeeds against the deleted account state. That gives the postmortem something stronger than an aggregate success rate.
Be precise about what βrevoke every sessionβ promises. A signed token checked only for signature and expiry cannot observe this model's account version change. Every protected authorization path must consult authoritative revocation state or a mechanism with an explicitly accepted propagation bound. If a cache permits stale acceptance, immediate revocation isn't the property you have implemented. A request authorized before deletion may already be in flight; sensitive writes need their own account-state check at commit if the deletion boundary must also stop those writes.
Roll out the decision check across browser sessions, mobile sessions, and background work acting on a shopper's behalf before calling the deletion workflow complete. Define how authorization behaves when the revocation store is unavailable. Rejecting protected operations preserves the chosen security boundary but creates visible availability friction; accepting them would change the promise being made to the shopper.
That lookup and its availability requirements are part of the operating cost, along with message traffic and support contacts from users who requested a replacement code. Counting only delivered messages misses the work. Ownership also needs to be explicit: the authentication team controls the invariant, while whoever implements account deletion must exercise it through the same shared state.
4. Set the page threshold against the invariant
Close the drill with the page that should have fired earlier: an authorization accepted against account state already known to be deleted. A proposed policy is to page on one confirmed violation of that invariant. βConfirmedβ requires authoritative ordering, because log arrival order alone cannot distinguish a late event from a late authorization. Don't put a shopper's deletion timestamp on one service clock and compare it casually with another service's request timestamp.
Keep expected rejections out of that page. A shopper entering an old SMS code after switching to email is evidence that replacement works, although a sustained increase may justify an interface investigation. Code expiry is also expected. Delivery latency deserves its own service-level signal and threshold, with a window chosen from observed traffic rather than an invented universal number.
Thresholds have a human cost. Paging on every stale-code rejection turns ordinary fallback friction into an interruption, while waiting for a large batch of confirmed post-deletion acceptances tolerates the very failure the account-deletion workflow is supposed to prevent. Choose the security invariant first, verify the event ordering, and reserve the page for evidence that access crossed that boundary.
References
The authentication controls discussed above are grounded in the following guidance. Example thresholds and the executable state model are design choices, not claims about a commercial service.
Further reading
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.