How to Use Code for Generating and Sending One-Time Passwords via SMS: Logistics Ownership
Short answer: keep the reset token, expiry, send attempt, and message template under one application-owned workflow. Generate a cryptographically secure code, store only a digest with a short deadline, claim the send ope
Short answer: keep the reset token, expiry, send attempt, and message template under one application-owned workflow. Generate a cryptographically secure code, store only a digest with a short deadline, claim the send operation idempotently, and make the SMS provider an adapter. That arrangement gives an on-call engineer something useful to inspect when a driver cannot sign in, without making the provider's template system part of the security boundary.
In a logistics system, a password reset is usually a small event inside a large operational day. A dispatcher may need access before a route changes; a driver may be standing beside a loading bay with one phone and poor reception. The reset message must arrive quickly, but a late message must not become a second valid credential. Template ownership matters because the text, locale, sender identity, and incident response path all change when the template lives outside the service that owns the reset state.
What should the reset workflow own?
The application should own four facts: which account requested the reset, which challenge is current, when it expires, and whether the notification request has already been accepted. The message service can deliver bytes and report delivery events. It should not decide which code is valid.
I initially treated SMS as the final step after token generation. That split hides the dangerous part: retries can create two messages for one challenge, and a delayed first message can arrive after a newer code. The fix is a state transition with a stable challenge ID. A request can be retried; the state transition cannot be replayed as a new reset unless policy explicitly allows it.
Use a short lifetime such as 10 minutes for this example, then set a limit on attempts. The exact values belong in threat modeling and support policy. A shorter expiry reduces the window for interception, while a longer one helps users in areas with weak coverage. Those pressures are real; picking a number because it is familiar is not an operational policy.
The record needs enough information to answer an incident review without retaining the secret itself:
-
challenge_id, account ID, and normalized phone number reference - a digest of the code, expiry timestamp, consumed timestamp, and attempt count
- a template version and locale selected by the application
- an idempotency key for the outbound notification
The phone number should be masked in logs. Never log the code, the full reset URL, or an unredacted provider payload.
How should code for sending one-time passwords via SMS handle retries?
The reader question is often phrased as “how do I send an OTP through an API?” The send call is the easy part. The security property comes from generating the value with a cryptographic random source, storing a verifier, and consuming it atomically. In Go, crypto/rand is the right primitive for a six-digit example.
That is the boundary.
package otp
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
)
func GenerateCode() (string, error) {
var bytes [4]byte
if _, err := rand.Read(bytes[:]); err != nil {
return "", fmt.Errorf("generate OTP entropy: %w", err)
}
n := uint32(bytes[0])<<24 | uint32(bytes[1])<<16 |
uint32(bytes[2])<<8 | uint32(bytes[3])
return fmt.Sprintf("%06d", n%1000000), nil
}
func Digest(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
func FormatMessage(code string, expiresInMinutes int) string {
return "Your logistics account reset code is " + code +
". It expires in " + strconv.Itoa(expiresInMinutes) + " minutes."
}
Modulo reduction introduces a small bias because 2^32 is not a multiple of 1,000,000. For a higher-assurance design, use rejection sampling or a standardized HOTP/TOTP implementation. The important boundary remains the same: the code is generated in the application, and the outbound adapter receives a rendered message rather than authority to mint credentials. RFC 6238 describes time-based one-time passwords; an SMS reset can use a separate random challenge, but it should retain the same one-time consumption rule.
Here is the adapter boundary. It is intentionally boring. Boring boundaries are easier to retry and replace.
package notify
import "context"
type SMSMessage struct {
To string
Body string
IdempotencyKey string
}
type Sender interface {
Send(context.Context, SMSMessage) error
}
func SendReset(ctx context.Context, sender Sender, message SMSMessage) error {
if message.To == "" || message.Body == "" || message.IdempotencyKey == "" {
return ErrInvalidMessage
}
return sender.Send(ctx, message)
}
A production implementation should attach the key to the provider request if the provider supports idempotency. If it does not, persist an outbound record and make the worker claim that record with a lease. A timeout after the provider accepted the request is ambiguous: retrying may duplicate the SMS, while refusing to retry may leave the user waiting. Record that ambiguity and reconcile delivery status later; do not pretend a network timeout proves that nothing was sent. The main limitation is operational overhead: this application-owned pattern is a poor fit for a tiny internal tool with no queue, audit requirement, or recovery workflow; a simpler one-shot notification can be easier to operate there.
Which failure signal should page the team?
The useful signal is not merely “API call returned an error.” Track the challenge lifecycle: created, send accepted, send rejected, delivered if available, verified, expired, and locked after too many attempts. Measure each transition with the challenge ID, template version, locale, and provider request ID, while excluding the code and phone number.
A queue worker needs three separate policies. A transient transport failure can be retried with bounded backoff. A permanent recipient or policy failure should move to a terminal state and expose a support-safe message. An unknown result after a timeout needs reconciliation or a deliberately chosen duplicate policy. Mixing all three into one retry loop is how duplicate messages become a normal Tuesday.
The verification transaction should enforce the invariants, not the HTTP handler. In SQL-shaped pseudocode, the update must require an unconsumed, unexpired challenge and increment the attempt count in the same transaction. A successful match consumes it immediately. Two concurrent requests must not both win.
package verify
import (
"crypto/subtle"
"errors"
"time"
)
var ErrRejected = errors.New("reset code rejected")
type Challenge struct {
Digest []byte
ExpiresAt time.Time
Consumed bool
Attempts int
}
func Check(now time.Time, challenge Challenge, suppliedDigest []byte) error {
if challenge.Consumed || !now.Before(challenge.ExpiresAt) || challenge.Attempts >= 5 {
return ErrRejected
}
if subtle.ConstantTimeCompare(challenge.Digest, suppliedDigest) != 1 {
return ErrRejected
}
return nil
}
The function above is a policy check, not a complete persistence transaction. The database operation still has to lock or conditionally update the row, then mark it consumed only after the comparison succeeds. A failed attempt should increment the counter under the same concurrency control. Returning the same rejection to an expired, unknown, and incorrect code also limits account enumeration.
What belongs in the template and deployment runbook?
Keep the template in the application repository or another application-controlled, versioned store. Give each rendered message a version such as reset-sms-v3; store that version with the challenge. This makes a later incident legible: support can distinguish an old message from a current one, and a rollback can restore known wording without reviving an old token.
The message should identify the action and expiry, avoid placing sensitive account data in the body, and tell the recipient what to do if they did not request it. Do not include a password or a reusable reset secret. Locale selection should happen before the send record is created, so a retry does not silently switch languages after deployment.
Test the workflow at four boundaries: code generation, atomic verification, provider timeout handling, and template rendering. Include a test for two workers claiming one outbound record, a test for a message arriving after expiry, and a test that a second verification fails after the first succeeds. These tests are more valuable than a happy-path mock that only checks whether Send was called.
For rollback, keep the challenge schema backward compatible and roll back the template pointer separately from application code. If delivery quality drops, pause new sends behind a feature flag while allowing already-created challenges to expire normally. Reissuing every challenge during an incident creates a larger blast radius and can flood the same phone numbers.
The operational rule is straightforward: the reset service owns truth, the template is versioned with that truth, and the SMS API is a replaceable delivery edge. That division makes expiry, retries, and audit evidence testable without asking a provider dashboard to explain a security decision. The trade-off is clear: the team must operate durable challenge state and reconciliation. If the organization cannot staff that responsibility, a managed identity flow may be the more honest boundary, provided its expiry and audit behavior meet the requirement.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.