Short answer: for a logistics portal that emails a generated report, keep OTP state server-side, make send and verify operations idempotent, and enforce one shared rate-limit and cooldown policy before any message leaves your system. The least complex design is a small authentication service in front of the report job, with an audit record that can prove who requested the attachment and when.
The page that wakes an on-call engineer is usually not the login form. It is a compliance alert: a report attachment was delivered, but the evidence trail cannot connect the request to a verified operator. In the incident review, the visible symptoms are duplicate SMS messages, a customer trying an old code, and a retry loop that keeps a carrier queue busy. The signal that should have fired earlier was a rising ratio of sends to successful verifies for one account, region, or IP range.
What evidence must exist before a report attachment leaves the system?
Treat the generated report as a protected action, not as a side effect of a successful password check. Bind an OTP challenge to a user, a purpose such as report-download, and a short expiry. Store a hash of the code, never the code itself. Keep the challenge status (pending, verified, locked, or expired) and an append-only audit event with a request ID.
NIST SP 800-63B describes OTP authenticators as replay-resistant only when the verifier accepts a given secret once and applies a defined lifetime. That translates into a single-use database update: compare the hash, then atomically mark the challenge verified. A second request with the same code must be rejected even if it arrives milliseconds later.
The attachment worker should consume a signed, short-lived authorization produced after verification. It should not receive the phone number or accept an OTP directly. This separation keeps a queue replay from becoming a second report delivery, and it gives compliance staff a compact chain: challenge created, code sent, code verified, report generated, attachment delivered.
A useful audit row is boring by design: request_id, actor ID, purpose, phone-number fingerprint, policy version, timestamps, result, and reason code. Keep message content out of the audit stream unless your retention policy explicitly allows it.
Keep it single use.
The attachment worker should consume a signed, short-lived authorization produced after verification. It should not receive the phone number or accept an OTP directly. This separation keeps a queue replay from becoming a second report delivery, and it gives compliance staff a compact chain: challenge created, code sent, code verified, report generated, attachment delivered. The chain also makes a later dispute tractable because each transition can be correlated with the same request ID instead of reconstructed from application logs that have different retention periods, time zones, or redaction rules.
How should a Node.js backend send and verify code without losing the retry boundary?
The policy must be evaluated before the send call and again before verification. A cooldown is a user-facing timer; a rate limit is a system-wide budget. They are related, but they are not interchangeable.
For example, a five-minute challenge lifetime can coexist with a 30-second resend cooldown, a maximum of five verification attempts per challenge, and a daily per-user send budget. Those are policy examples, not universal constants; tune them against carrier latency, fraud data, and the SLO for report access.
Here is the core shape in Go, using interfaces so the storage and SMS transport can be replaced without changing the rules:
type Challenge struct {
ID string
UserID string
Purpose string
CodeHash []byte
ExpiresAt time.Time
NextSendAt time.Time
Attempts int
Verified bool
}
type Store interface {
CreateChallenge(ctx context.Context, c Challenge) error
GetChallenge(ctx context.Context, id string) (Challenge, error)
ConsumeAttempt(ctx context.Context, id string, now time.Time) (Challenge, error)
MarkVerified(ctx context.Context, id string, now time.Time) error
}
type Messenger interface {
Send(ctx context.Context, destination, body, idempotencyKey string) error
}
func VerifyCode(ctx context.Context, store Store, challengeID, submitted string, now time.Time) error {
c, err := store.ConsumeAttempt(ctx, challengeID, now)
if err != nil {
return err
}
if c.Verified || now.After(c.ExpiresAt) {
return errors.New("challenge_not_valid")
}
if !subtle.ConstantTimeCompare(hashCode(submitted), c.CodeHash) {
return errors.New("code_mismatch")
}
return store.MarkVerified(ctx, challengeID, now)
}
ConsumeAttempt must enforce the attempt ceiling in one transaction. Otherwise two parallel requests can both observe attempt four and both pass a five-attempt policy. The send path needs the same treatment: reserve the next-send timestamp and an idempotency key before calling the provider. A timeout after the provider accepted the message should produce an unknown delivery result, not an automatic second send.
Retry only failures that are safe to retry. A database serialization conflict can be retried with jitter; an accepted SMS request cannot be blindly replayed. Return a stable response such as challenge_accepted for repeated send requests while the cooldown is active, so clients do not turn a button double-click into two messages.
Which signal should fire before an OTP delivery dispute?
Start with the alert payload. It should include the policy version, challenge ID, and request ID, plus counters for sends, verifies, mismatches, expiries, and cooldown responses. A dashboard that only shows provider delivery hides the failure that matters: a code can arrive and still be unusable because the verifier clock or state transition is wrong.
Clock discipline deserves an explicit SLO. Use UTC timestamps from a trusted service, measure database-to-application skew, and alert before the skew can consume a meaningful part of the challenge lifetime. Your mileage may vary with carrier latency; measure p95 and p99 by destination rather than choosing a global expiry from intuition.
One early implementation I reviewed counted attempts in the web process. It looked correct in a single-instance test, then diverged when a second instance handled a retry. The fix was moving the counter into the transactional store and adding a metric for attempt_conflict_total. Small detail. Big evidence difference.
False positives have a cost. A threshold that pages on every short carrier delay trains the team to ignore the alert; a threshold that waits for a full day of failures leaves a report outside its evidence window. Set paging on sustained ratios and use a lower-severity signal for isolated destinations.
Do not guess.
Capacity planning belongs in the same review as the retry policy. Estimate peak report requests, the maximum number of verification attempts per request, and the SMS provider's acknowledgement latency, then reserve database write capacity for the worst simultaneous burst rather than the average hour. A useful load test submits parallel sends for the same user, parallel verifies for the same challenge, provider timeouts after acceptance, and retries arriving after the cooldown boundary. Observe whether the store preserves one challenge state, whether the messenger receives one idempotency key, and whether the audit stream keeps a complete order. This test is where an apparently generous SLO can expose a narrow connection pool or a queue that has no back-pressure; the remedy is a bounded worker pool and an explicit shed decision, recorded as a policy result, before the system starts dropping evidence silently.
Where do build and buy choices change the evidence you can produce?
| Decision area | Build or self-host when | Use a managed capability when | Evidence to retain |
|---|---|---|---|
| Challenge state | You need custom retention, regional storage, or an unusual approval workflow | A standard state machine meets your residency and export requirements | State transitions and policy version |
| SMS transport | You need routing control and already operate carrier integrations | You need broad coverage without taking carrier contracts on-call | Request ID, provider result, and timestamp |
| Abuse controls | Your fraud team owns adaptive limits and can tune them safely | You need a maintained baseline while your signals mature | Limit decisions and reason codes |
| Report delivery | The report pipeline is already an audited internal service | You need a hosted queue with contractual retention guarantees | Authorization ID and delivery event |
The catch is operational ownership. A managed sender can reduce paging for transport failures, but it cannot decide whether a report request is legally attributable to a person in your directory. Self-hosting gives control over that boundary and adds patching, capacity planning, and carrier escalation work. Stick with a managed component when your team cannot meet its availability and audit SLOs; build the state machine when generic retention or approval rules would leave a compliance gap.
Cost is a constraint, not the argument. Count engineering hours, audit exports, message spend, and the recovery work caused by duplicate sends. I am not sure a single global policy will hold across every country; the data that resolves that uncertainty is a destination-level latency and fraud report reviewed over a representative period.
Further reading
References
- NIST Digital Identity Guidelines, SP 800-63B: https://pages.nist.gov/800-63-3/sp800-63b.html
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
Top comments (0)