The operational constraint is evidence: an e-commerce signup must show why an OTP request was allowed or denied without retaining more personal data than the control needs. For SMS-based 2FA in Node.js, use layered limits keyed to the signup, a keyed phone digest, source network, and a short-lived challenge. Record the decision.
Short answer: the control needs a burst limiter, a longer abuse window, and a separate resend timer. Bind each OTP code to one challenge. Reserve the issuance slot before enqueueing delivery, and make retries idempotent.
What did the missed-job page teach me about OTP throttling?
I have been paged by missed jobs and duplicate deliveries. OTP issuance has the same failure shape: one logical request creates several physical attempts unless an idempotency boundary travels through the workflow.
For an online shop, the limiter decides before queueing. The worker receives an opaque challenge ID and refuses a stale challenge. The invariant is one active challenge, bounded issuance, and one auditable reason per decision. This is a signup control, not the whole authentication system. Recovery and assisted-support flows need separate policy keys and retention review.
How should Node.js rate-limit OTP requests for SMS-based 2FA?
Use independent dimensions:
| Dimension | Failure it catches |
|---|---|
| Challenge | Double-clicks and replayed submissions |
| Phone digest | Repeated requests to one destination |
| Signup token | Attempts against one registration |
| Source network | Distributed targeting from one origin |
| Queue | Backpressure and worker failure |
Do not copy thresholds from a blog post. Start with a short resend interval, a small burst allowance, and a longer rolling budget. Measure completion, denial reasons, delivery latency, and abandoned signups by policy version. A resend timer is a UX rule; an abuse budget is a security rule. Conflating them makes both tuning and support explanations worse.
Normalize the phone number according to the account policy, then derive a keyed digest with a secret unavailable to ordinary logs. A plain hash is not enough: phone numbers have a small, guessable search space. Plan secret rotation as a policy migration.
How do you preserve a decision across retries?
The ordering is reserve, record, enqueue. A transactional database operation or atomic rate-limit operation should reserve the slot and create the challenge together, or leave a state a reconciler can recover. If queue publication fails after reservation, retry with an idempotency key. Do not issue a second code silently.
This Go boundary is storage- and provider-neutral:
package otp
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"time"
)
type Decision struct {
Allowed bool
Reason string
RetryAt time.Time
}
type Limiter interface {
CheckAndReserve(context.Context, []string, time.Time) (Decision, error)
}
func phoneKey(secret []byte, normalized string) string {
h := hmac.New(sha256.New, secret)
_, _ = h.Write([]byte(normalized))
return hex.EncodeToString(h.Sum(nil))
}
The production implementation still needs durable challenge storage, atomic CheckAndReserve, and a defined queue contract. A decision record should contain a correlation ID, policy version, coarse source classification, keyed destination digest, result, reason, and expiry. It should not contain the OTP.
Monitoring is part of the same control.
Track requests, reservations, denials by reason, accepted challenges, successful verification, duplicate delivery attempts, queue age, and delivery responses. Page on queue age crossing the signup SLO, a sudden change in denial reasons, duplicate challenge IDs, or a mismatch between reserved and published work. Do not page on every denial; denial is often the control working.
Test the state machine across window boundaries, concurrent requests for one phone digest, challenge reuse, queue retry after timeout, and process restart. Two application instances receiving the same signup at nearly the same time is the important test. An in-memory counter can pass single-process tests and fail this one. Shared state must provide atomicity, or the policy must explicitly accept advisory limits.
Compliance evidence is not a dashboard screenshot. This approach has a limitation: it can prove the issuance decision, but it cannot prove that a carrier delivered an SMS. Keep delivery-provider responses as a separate, access-controlled record.
Retain the policy version, input categories, decision, expiry, and retention metadata. The record should answer which flow ran, which policy evaluated it, which limit caused the result, when the reservation expires, and whether delivery completed or was superseded. A reviewer should not need the OTP or a reconstructed phone number.
The right retention period depends on the organization’s obligations and incident process. Document access, deletion, and key rotation effects. The decision is not a magic request count: it is bounded issuance, harmless retries, and explainable outcomes.
Top comments (0)