DEV Community

callumreed2198
callumreed2198

Posted on

Build a Secure OTP Service: Custom Node.js Generation, Validation, and External SMS

Short answer: make the Node.js service own code generation, expiry, attempt limits, and one-time consumption; make the external SMS API own delivery. Store only a digest of each code, bind it to a login transaction, and record an idempotency key for every send request. For a customer-support portal that can expose customer data and generate report attachments, this boundary matters more than the SMS provider's feature list.

The useful mental model is a small state machine, not a random-number helper. A request creates a pending challenge. A successful check consumes it. Every other path is an explicit rejection: expired, already used, too many attempts, wrong transaction, or an upstream delivery failure. That gives the on-call engineer something precise to inspect when a scheduled report is delayed or a user receives two messages.

Why does a support portal need a stricter boundary?

A support agent may request a generated report containing account history, then download it from an authenticated workflow. The OTP is one gate in that chain. It should not become a second source of truth for identity, report authorization, or attachment delivery. Keep those concerns separate.

The common operational failure is a retry that looks harmless. A client times out after the service has accepted a send request, so it retries. A queue worker also retries after an ambiguous response. Without a stable challenge identifier and a delivery idempotency key, the user sees two valid-looking codes. The support desk then asks which message to trust, while the incident timeline says only “SMS sent.” That is not enough. After being paged for missed jobs and duplicate deliveries, I treat that ambiguity as an incident signal, not a cosmetic messaging problem.

Duplicates are a state-management problem.

I use a five-minute challenge lifetime and a six-digit code as a starting policy, then verify that the threat model and rate limits support it. The lifetime is a decision, not a universal constant: a high-risk administrative action may need a shorter window, while a busy call-center flow may need a carefully bounded resend path. The service must make the choice visible in configuration and telemetry.

The code itself should be generated with a cryptographically secure random source. RFC 4226 describes HOTP, and RFC 6238 describes TOTP; an SMS challenge is a different delivery mechanism, but those documents are useful reminders that predictability and replay are protocol concerns. Do not use a timestamp, a counter exposed to the client, or a language-level pseudo-random generator intended for simulation.

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

For this workflow, the service should own security facts: challenge ID, subject binding, code digest, expiry, attempt count, consumed timestamp, and delivery state. The message template should own presentation: sender label, localized wording, and the sentence telling the user where to enter the code. Template ownership is the real design axis because changing copy must not silently change validation rules.

A good record is deliberately boring:

type Challenge struct {
    ID             string
    Subject        string
    CodeDigest     []byte
    ExpiresAt      time.Time
    Attempts       int
    MaxAttempts    int
    ConsumedAt     *time.Time
    DeliveryKey    string
    DeliveryStatus string
}
Enter fullscreen mode Exit fullscreen mode

The database write that creates the challenge and the write that marks it consumed need clear concurrency semantics. On validation, update only a record whose consumed_at is null, whose expiry is still in the future, and whose attempt count is below the limit. Check the affected-row count. Two concurrent requests must not both receive success. A process-local mutex is not sufficient when the service has multiple replicas.

Hash the code before storage, and compare a freshly computed digest with a constant-time comparison. A keyed digest can make lookup and rotation policy easier, but the important property is that a database reader cannot immediately turn a copied row into a login code. Delete or age out old records according to the retention policy; logs should never contain the code, the full phone number, or the complete message body.

Plaintext codes do not belong in logs.

The validation path can be expressed with a generic store interface. The application can implement it with a relational transaction, but the security rule should remain readable in tests:

func ValidateChallenge(ctx context.Context, store Store, id, subject, code string, now time.Time) error {
    challenge, err := store.LockChallenge(ctx, id)
    if err != nil {
        return ErrInvalidChallenge
    }

    if challenge.Subject != subject || challenge.ConsumedAt != nil {
        return ErrInvalidChallenge
    }
    if !now.Before(challenge.ExpiresAt) || challenge.Attempts >= challenge.MaxAttempts {
        return ErrInvalidChallenge
    }

    digest := digestCode(code)
    if !hmac.Equal(digest, challenge.CodeDigest) {
        if err := store.IncrementAttempt(ctx, challenge.ID); err != nil {
            return ErrInvalidChallenge
        }
        return ErrInvalidChallenge
    }

    if err := store.Consume(ctx, challenge.ID, now); err != nil {
        return ErrInvalidChallenge
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The public response should not reveal whether a phone number has an account, whether a challenge ID exists, or whether a submitted code was almost correct. Internally, retain a reason code with access controls so operators can distinguish abuse from expired sessions without turning the API into an account-enumeration oracle.

How should sending and retrying behave?

Create the challenge once, then send it through a queue or an equivalent durable boundary. The send job carries the challenge ID and delivery key, not the plaintext code. A worker loads the encrypted or otherwise protected message material needed for the handoff, submits the request, and records the result. The worker may retry timeouts, but retries must be safe to repeat.

The delivery key should be stable for one logical send, such as otp:{challengeID}:initial. A resend is a new policy decision. It may invalidate the previous challenge and create a new key, or it may be suppressed until a cooldown expires. Do not let a generic HTTP retry library decide this policy.

External APIs differ in timeout behavior, response semantics, and duplicate handling. Treat a timeout as “delivery unknown,” not “delivery failed.” Use a bounded deadline, classify the result, and surface an operator metric for unknown outcomes. A retry can produce a duplicate message when the remote system accepted the request before the connection broke. That is why the challenge state and the delivery key belong to the application domain.

A minimal sender contract keeps provider-specific code at the edge:

type SMSMessage struct {
    Destination string
    Body        string
    Idempotency string
}

type SMSClient interface {
    Send(ctx context.Context, message SMSMessage) (string, error)
}

func Dispatch(ctx context.Context, client SMSClient, message SMSMessage) error {
    ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
    defer cancel()

    _, err := client.Send(ctx, message)
    return classifyDeliveryError(err)
}
Enter fullscreen mode Exit fullscreen mode

The 8-second deadline above is an example service policy, not a promise about a remote API. Choose it from observed latency, queue age, and the user journey. A shorter deadline can increase unknown outcomes; a longer one can pin workers and delay later jobs. That is a real trade-off, and it belongs in the runbook.

For a Node.js implementation, the same boundaries map cleanly to a route handler, a transactional repository, and a worker process. Keep the provider adapter behind an interface, pass an abort signal into the HTTP client, and make the persistence transaction the authority for consumption. The language is incidental; the state transitions are not.

What do I verify before calling the flow healthy?

Start with tests that try to break the state machine. A code succeeds once. The same code fails after consumption. A code from one subject fails for another. An expired code fails even if its digest matches. The maximum-attempt boundary is exact. Two simultaneous validations yield one success. These are cheap tests and catch more than a happy-path SMS mock.

Then test the delivery boundary with injected outcomes: accepted, rejected, timeout, malformed response, and retry after an ambiguous result. Confirm that a retry reuses the same delivery key and that a new resend follows the documented invalidation rule. Test template changes separately from code validation. A localized message can change line breaks and wording without changing the code or its lifetime.

The production dashboard should show rates and ages, not secrets. Useful signals include challenge creation rate, validation rejection categories, consumed-to-created ratio, queue age, send latency, timeout rate, resend rate, and delivery status by region. Alert on a sustained change in behavior, such as a growing queue with normal application traffic or a sharp increase in invalid attempts for one subject range. Avoid putting phone numbers, codes, or report contents into labels.

A rollback plan needs two layers. If a template release is bad, restore the previous template without changing challenge records. If the provider adapter is bad, stop new dispatches or route them through a previously verified adapter while preserving the existing challenge state. Never solve a send incident by weakening validation, extending every expiry, or accepting a code twice.

The operator's question during an incident should be answerable from one challenge ID: was it created, queued, handed to the adapter, acknowledged, consumed, expired, or rejected? That sequence is more useful than a vendor status page copied into a ticket.

Decision rule

Choose the simplest design that preserves four invariants: codes are unpredictable, codes are bound to one transaction, successful validation is single-use, and delivery retries are observable and idempotent. Give security fields to the service and copy changes to the template owner. Keep the external SMS API replaceable.

For a customer-support portal, this means an OTP can protect the report workflow without becoming an unreviewable tangle of message copy, provider behavior, and login state. The implementation is small. The operational contract is the product.

References

Top comments (0)