Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

Node.js Service Evidence Design — Build Secure Custom OTP Validation Around SMS

The compliance page should fire when an OTP delivery record becomes incomplete, not when an auditor later discovers that a login has no explanation. For a B2B SaaS login flow, the reliable design is to make the challenge

The compliance page should fire when an OTP delivery record becomes incomplete, not when an auditor later discovers that a login has no explanation. For a B2B SaaS login flow, the reliable design is to make the challenge, the SMS attempt, and the verification decision separate records joined by one correlation ID. The code is generated inside the service, stored as a digest, sent through an external SMS API, and never treated as proof of delivery merely because the API accepted a request.

Short answer: build a small state machine around the OTP challenge, give every transport attempt an explicit outcome, and alert on missing or late evidence against an authentication SLO. This keeps the integration replaceable while preserving the audit trail your compliance process actually needs.

What should the on-call see when the page fires?

Start with the page, because alert design exposes the architecture's real assumptions. The on-call should see a challenge ID, tenant-safe metadata, the current state, the age of the oldest unresolved attempt, and whether the failure happened before or after the external SMS request. They should not see the OTP, a full phone number, or a provider credential.

Work backwards from that page. A useful event sequence is challenge_issued, sms_submitted, sms_acknowledged, sms_failed, otp_verified, otp_expired, and otp_locked. The sequence is more important than the event names: each transition must be attributable, timestamped, and safe to replay into an audit export. A transport acknowledgement says what the remote API accepted. It does not establish that a handset received the message or that a person entered the code.

This is where I initially wanted one status column. It looked tidy. It was also ambiguous: failed could mean code generation, network submission, carrier rejection, or a user exceeding the attempt limit. Separate challenge state from delivery-attempt state. The extra rows are cheaper than reconstructing intent from logs after a review.

The first instrumentation change should measure these intervals independently: challenge creation to SMS submission, submission to acknowledgement, and challenge creation to successful verification. Alert thresholds should be tied to the authentication SLO and evaluated over a window that reflects normal burst traffic. A page on every transient timeout creates false positives; a page only after completed-login failures arrives after the security control has already disappointed users.

How should a custom Node.js service build secure OTP validation?

Treat SMS as an untrusted transport and the validation service as the security boundary. Generate an unpredictable code with a cryptographic random source, store a digest rather than the code, set an expiry, count failed attempts, and consume the challenge atomically. NIST SP 800-63B requires rate limiting for failed authentication attempts, while OWASP recommends generic authentication responses that do not disclose which account or factor failed.

The reader asked for a Node.js service, but the security invariant is language-independent. This Go sketch shows the part worth testing directly: rejection of weak randomness and a fixed-width code representation. The surrounding service should persist the returned digest and keep the cleartext value only long enough to submit the message.

package otp

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/binary"
    "fmt"
)

func NewCode() (string, [32]byte, error) {
    var raw [4]byte
    if _, err := rand.Read(raw[:]); err != nil {
        return "", [32]byte{}, err
    }

    value := binary.BigEndian.Uint32(raw[:]) % 1000000
    code := fmt.Sprintf("%06d", value)
    return code, sha256.Sum256([]byte(code)), nil
}

The modulo operation introduces a small distribution bias because the 32-bit range is not a multiple of 1,000,000. For a security-sensitive implementation, use rejection sampling or a library primitive that provides an unbiased bounded integer. That detail is easy to miss in a six-digit example, which is why it belongs in a test and in a code review checklist rather than in an optimistic comment. The bound is concrete: a six-digit space has 1,000,000 possible displayed values, so the attempt limit and expiry policy carry much of the practical protection.

Validation should happen in one transaction: locate the challenge by an opaque ID, verify that it is still issued, check the expiry and attempt count, compare the submitted value with the stored digest using a constant-time comparison, and mark it consumed on success. A second request must not be able to validate the same challenge after the first transaction commits. Return the same externally visible failure shape for an unknown, expired, locked, or incorrect code.

Where does the audit record stop making promises?

Define the evidence boundary before choosing an SMS interface. The application can prove that it generated a challenge, attempted submission, received a response, and accepted or rejected a validation. It usually cannot prove that a particular human read a handset message unless a separate factor supplies that evidence. Recording an external request ID is useful; presenting it as delivery confirmation is not.

Keep an append-only event record with a schema version, challenge ID, tenant ID, event type, event time, redacted destination reference, and transport attempt ID. Do not put the OTP in structured logs, trace attributes, metrics labels, URLs, or custom provider fields that may be echoed into support tooling. Retention and deletion rules need to cover both the challenge table and the event store; otherwise the “auditable” design quietly becomes a second copy of sensitive authentication data.

The record is the product.

Retries belong to the transport attempt, not to code generation. Derive an idempotency key from the challenge ID and attempt number when the external interface supports one, and persist the attempt before sending. A timeout then has a precise meaning: the service lacks a response, not that the message was definitely absent. Allowing a retry should be a bounded policy decision, with the challenge lifetime and user-facing resend behavior considered together.

Here is the awkward case worth testing before launch. The database commit succeeds, the process sends the request, and the process dies before it records the response. On restart, a blind retry can create two messages for one challenge; refusing every retry can leave a valid user waiting forever. The service needs a reconciliation state for that gap, a maximum attempt count, and an operator-visible distinction between “unknown outcome” and “failed.” A five-minute challenge lifetime is a policy example, not a security guarantee: it may be too short for a congested region and too long for a high-risk action. The right value comes from the authentication threat model, observed delivery latency, and the number of verification attempts allowed. The point is to make that choice explicit, version it with the challenge schema, and test it with a clock that can move past expiry. Otherwise the retry policy lives inside an SDK, where an on-call engineer cannot explain it from the audit record.

What capacity signal protects the authentication SLO?

Plan for the burst after a tenant-wide login event, not the daily average. Queue depth alone is a weak signal because a queue can drain while the oldest item is already too old to help a user. Track oldest pending challenge age, submission latency, provider response classes, validation attempts per challenge, and the proportion of challenges that expire without a recorded submission.

Keep separate budgets for the application and the external dependency. An application SLO might cover accepted challenge creation through a recorded submission attempt; a product metric can cover completed verification. Combining those intervals hides whether the queue, the network, the SMS API, or the user journey is responsible for the gap.

Thresholds need calibration. If a single slow response pages the team, operators learn to dismiss the page. If the threshold is based on a weekly average, a regional failure can sit unnoticed during a busy hour. Start with an alert on a sustained increase in unresolved challenges and a separate, lower-urgency signal for latency approaching the challenge expiry window. Revisit both after traffic changes; the number is a policy input, not a universal constant.

This is a hard boundary.

Buy or build the evidence boundary?

The integration decision is about ownership of state and evidence, not a feature-count contest. A managed verification workflow can reduce the transport and abuse-control surface, but its event model may not expose every transition required by an audit. A raw SMS API keeps the challenge lifecycle in the application, which means the team owns idempotency, retention, rate limits, and the operational response. A self-hosted delivery path offers the most control and the largest on-call surface.

Boundary Managed verification Raw SMS API Self-hosted delivery
Challenge lifecycle Usually delegated Application-owned Application-owned
Audit event shape Constrained by interface Customizable Customizable
Transport operations Mostly delegated Shared with application team Fully owned
Migration risk Workflow and event semantics Message interface Infrastructure and carrier contracts

Use the smallest boundary that can produce the evidence your policy requires. Before committing to an interface, write down the events an auditor or incident responder will ask for: issuance, submission, acknowledgement, expiry, lockout, validation, and deletion. Then test the interface with delayed responses, duplicate callbacks, rejected destinations, and a process restart between persistence and network submission. Integration effort is measurable in these missing transitions, not in how quickly the first message appears on a phone.

The decision rule is blunt. If the team cannot staff challenge-state ownership, a delegated workflow may be the responsible boundary. If compliance needs a custom, replayable record and the external workflow cannot represent it, keep the record in the service and accept the operational work that follows. The limitation of the custom path is operational ownership: a team that cannot own retention, abuse response, and on-call escalation should choose a delegated workflow. The trade-off reverses when that workflow cannot satisfy the audit policy or expose the required events. Neither choice removes the need for rate limiting, secret handling, or a clear statement of what delivery evidence means.

Further reading

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.