DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Node.js SMS OTP Code for Verifying Logistics Phone Access — Audit Evidence

Short answer: verify the phone challenge as a short-lived, single-use authentication event, then record enough metadata to show what happened without storing the code itself. For a logistics portal that emails a generated report, that means the SMS check protects access to the report and the audit record proves the decision. Delivery is only one part of the system.

The useful design boundary is small: create a challenge, bind it to a login attempt and a phone number, send a code through an abstract provider interface, and consume the challenge atomically after a successful comparison. Keep the report attachment out of the SMS path. A delayed text must not cause a second report to be generated, and a report failure must not make a valid phone verification look invalid.

That distinction matters.

What must the verification record prove?

In a warehouse or carrier portal, an operator may request a generated delivery report and receive it as an email attachment. The sensitive event is not just “a text was sent.” An auditor may need to reconstruct the decision: which account initiated it, which phone-number reference was challenged, when the challenge expired, how many attempts were made, and whether the code was accepted before the report was released.

I keep the evidence record separate from message content. It contains a challenge identifier, account identifier, a one-way code digest, creation and expiry timestamps, attempt count, delivery outcome, and final state. It does not contain the plaintext OTP, the full phone number, or the report attachment. A keyed digest or an encrypted reference can make correlation possible without turning an audit export into a credential dump.

This is also where email and SMS concerns meet. DKIM can help a receiving system evaluate signed email, but it does not authenticate an SMS recipient and it does not prove that a report was opened. The application has to record those distinct claims separately.

How should Node.js code verify a phone number with SMS OTP?

The verification endpoint should load the challenge by an opaque identifier, reject an expired or already-consumed record, compare a digest using a constant-time operation, and update the record in the same transaction as the successful decision. A retry after success should return a stable “already used” result, not run the report workflow again.

Here is the relevant logic in Python 3.12. The storage and sender are deliberately interfaces; in a Node.js service, the same boundaries belong behind the route handler rather than inside it.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets


@dataclass
class Challenge:
    id: str
    account_id: str
    phone_ref: str
    code_digest: bytes
    created_at: datetime
    expires_at: datetime
    attempts: int = 0
    status: str = "pending"


def create_challenge(account_id: str, phone_ref: str) -> tuple[Challenge, str]:
    code = f"{secrets.randbelow(1_000_000):06d}"
    now = datetime.now(timezone.utc)
    challenge = Challenge(
        id=secrets.token_urlsafe(18),
        account_id=account_id,
        phone_ref=phone_ref,
        code_digest=hashlib.sha256(code.encode("ascii")).digest(),
        created_at=now,
        expires_at=now + timedelta(minutes=5),
    )
    return challenge, code


def verify_code(challenge: Challenge, supplied_code: str, now: datetime) -> bool:
    if challenge.status != "pending" or now >= challenge.expires_at:
        return False
    if challenge.attempts >= 5:
        challenge.status = "locked"
        return False

    challenge.attempts += 1
    supplied_digest = hashlib.sha256(supplied_code.encode("ascii")).digest()
    if not hmac.compare_digest(challenge.code_digest, supplied_digest):
        return False

    challenge.status = "consumed"
    return True
Enter fullscreen mode Exit fullscreen mode

The example is intentionally incomplete as application code. create_challenge must persist the record before sending, and the send result must be associated with the same challenge. verify_code must run under a row lock or an equivalent compare-and-set operation; otherwise two simultaneous requests can both observe pending and both release the report. The five-attempt limit and five-minute lifetime are policy examples, not universal constants. Set them from the threat model, carrier behavior, and compliance policy, then test the boundary values.

The trade-off is deliberate: a short lifetime limits replay, while a longer lifetime tolerates carrier delay. A strict attempt cap limits guessing, while an aggressive cap creates more support work for a driver in a poor coverage area. I would rather make those choices explicit in policy than hide them in a messaging library.

Do not log code, the full destination number, or the report contents. Log a redacted phone reference, challenge ID, request correlation ID, and outcome. Rate-limit by account, destination, and source signal. A destination-only limit is easy to evade with many accounts; an account-only limit can be abused to block one legitimate operator.

Which failures belong to delivery, and which belong to authentication?

SMS delivery has gaps: a carrier can delay a message, a user can request several codes, and a handset can be offline. Model these as state transitions rather than exceptions that leak provider details to the browser. “Accepted,” “expired,” “too many attempts,” and “not delivered” need distinct internal events, even if the public response is intentionally similar for account-enumeration resistance.

The resend operation should invalidate the earlier pending challenge or make the newest challenge the only acceptable one. Otherwise a user who receives two messages cannot tell which code wins, while an attacker gets more guesses. The UI can offer a resend action, but the server owns the policy.

Do not reuse the code.

Consider a concrete sequence: an operator requests a report at 09:00, taps resend at 09:04, enters the first message at 09:05, and taps submit twice because the page appears slow. The evidence should show two challenge IDs, one invalidated by resend, one consumed at most once, and one authorization decision tied to the report request. If the database records only “OTP passed,” an investigator cannot distinguish a stale code, a duplicate browser request, and a second report release. The event model therefore needs correlation IDs and idempotency keys at the report boundary, not just at the SMS boundary. The email worker should accept a report job only after that authorization decision exists, and it should retain the job’s own state when attachment generation or mail submission fails. This is a little more schema work up front. It is much less ambiguity during a review.

For the logistics workflow, successful verification should authorize a narrowly scoped action such as “release report run 8f2…” for one account and one time window. It should not create a broad session privilege by accident. Generate the report, attach it to the email job, and emit separate events for authorization, generation, and email submission. This separation makes a failed attachment easier to investigate without rewriting authentication evidence.

Portability has a cost. Keeping this state in the application means owning throttling, retention, incident response, and provider failover. A hosted messaging integration can reduce delivery plumbing, but it cannot decide which report an operator may release or what evidence the auditor needs. An authenticator app can reduce dependence on carrier delivery, but it changes enrollment, recovery, and device-loss procedures. SMS is a reasonable fit when the logistics workforce already has managed phone records; it is a weak fit for high-risk actions or users who routinely share devices.

How do you roll this out without losing the audit trail?

Start with a contract test around the challenge store and sender. Cover expiry at the exact boundary, a sixth attempt, two concurrent successful submissions, resend invalidation, duplicate callbacks, and a phone number changed between challenge creation and verification. These cases find more serious defects than a happy-path test that only types six digits.

During rollout, keep a small, explicit event vocabulary and publish counters for challenge creation, send acceptance, verification success, expiry, lockout, and report authorization. Alert on changes in ratios, not on a single delayed message. Retain the evidence according to the organization’s policy, encrypt it in transit and at rest, and restrict who can query it. A compliance record that every engineer can casually read is an access-control problem.

The decision rule is straightforward: choose a delivery integration that exposes enough outcome information for operations, but keep challenge state, throttling, evidence, and report authorization in your service. That preserves portability and makes the security claim testable. The transport can change; “this account passed this one-time challenge before this report action” should remain an application fact.

The limitation is important: SMS proves control of a phone number at that moment, not the identity of a person and not the integrity of an emailed attachment. Pair it with account controls, least-privilege authorization, and a verifiable report workflow. That is the honest boundary of the mechanism.

That is an audit fact, not a delivery fact.

Sources

Top comments (0)