DEV Community

IgnatiusCole6932
IgnatiusCole6932

Posted on

SMS OTP vs Email OTP: Reliable SaaS Login for Construction Site Access

Short answer: for construction site access, don't declare SMS OTP or email OTP the universal winner for US and EU users; route each verified worker to the channel that meets an end-to-end login SLO at that site, preserve the other as a rate-limited fallback, and judge success at code redemption rather than message acceptance.

That distinction decides the architecture. A transport can accept a message while the worker still cannot open the gate, so the useful service-level indicator runs from challenge creation to successful, one-time redemption. Provider acceptance belongs in the trace, but it isn't the outcome. I would set the target only after observing a site's shift pattern and then publish the exact numerator, denominator, expiry policy, and excluded maintenance windows. I'm not sure a new site with sparse traffic can support a defensible channel preference; until the sample is useful, a tenant-level default plus supervised recovery is the less speculative choice.

Define the incident around the locked gate

Use a bounded incident scenario: a morning shift begins, many workers request codes within the same few minutes, and some reach the entrance before their messages do. The authentication API remains healthy and both transports accept work. Yet the access workflow is unavailable because valid challenges expire or workers trigger overlapping retries. The invariant exposed by this scenario is blunt: a sent OTP is not a completed login.

Build the incident timeline around one opaque challenge ID. Record creation, enqueue, transport acceptance, client-visible fallback offers, verification attempts, invalidation, expiry, and redemption without storing the OTP itself. Slice the resulting latency and completion signals by site, channel, destination domain or mobile network where available, and shift window. Do not infer email arrival from an open pixel: Apple says Mail Privacy Protection prevents senders from learning whether a recipient opened an email and masks the recipient's IP address, so open telemetry cannot carry the SLO.

The first alert should normally be queue age or the ratio of redeemed challenges to issued challenges over a defined window, not raw send errors alone. An alert on acceptance can detect one narrow failure domain; it cannot tell the on-call engineer that fifty people are waiting at a physical entrance. Keep the page actionable — site, affected channel, oldest viable challenge, and fallback saturation — and leave campaign-style engagement metrics out of it.

Short queues matter.

Should SaaS login use SMS OTP or email OTP for construction site access?

Choose from observed operating conditions, not a US-versus-EU stereotype. Email is a reasonable primary path when workers have individually controlled mailboxes that they can reach during the shift and the sending domain is authenticated. SMS is a reasonable primary path when workers have verified phone numbers and usable mobile service at the entrance. Neither statement guarantees delivery, and neither channel should inherit a global preference merely because a billing address falls in one region.

Email and SMS fail differently. Email crosses mailbox filtering and domain-policy boundaries; SMS crosses mobile-network, device, and local coverage boundaries. DMARC provides a domain-level policy and reporting mechanism for email authentication, but it does not prove that a person received or redeemed a code. In the same way, a transport receipt is evidence about one leg of SMS delivery, not proof that the login finished. That is why the routing input should be successful redemption within the site's objective, backed by a minimum sample threshold, rather than opens, clicks, or accepted sends.

Delivery is local.

Decision signal Email-first policy SMS-first policy Stop condition
Worker access Individual mailbox is available at entry time Verified phone is available at entry time Destination ownership is uncertain
Site evidence Redemption meets the local objective Redemption meets the local objective Evidence is sparse or unstable
Failure isolation Mail and identity domains are separated Mobile and identity domains are separated Both paths share one critical dependency
Recovery SMS remains independently usable Email remains independently usable Fallback would weaken identity proofing

The table is a policy skeleton, not an answer produced once and forgotten. Re-evaluate after a site changes network coverage, workforce enrollment, mailbox policy, or shift timing. Your mileage may vary across two entrances on the same project, which is precisely why a country-wide rule is too coarse.

Make fallback one state machine, not two login systems

The common implementation mistake is to let each channel mint its own active OTP. A delayed email can then arrive after an SMS retry, leaving the worker with two plausible codes and support with an ambiguous audit trail. Keep a single challenge lineage instead: switching channels invalidates the earlier secret, consumes the same send budget, and retains the same tenant, user, purpose, and expiry boundary. The channel changes; the authorization decision does not.

Here is the preventative core. It deliberately leaves transport adapters outside the state transition, because the transaction should commit the new challenge before a worker sends it and should enqueue exactly one delivery command with the challenge ID as its idempotency key.

package otp

import (
    "crypto/rand"
    "fmt"
    "math/big"
    "time"
)

type Channel string

const (
    Email Channel = "email"
    SMS   Channel = "sms"
)

type Challenge struct {
    ID        string
    UserID    string
    Channel   Channel
    Digest    []byte
    ExpiresAt time.Time
    UsedAt    *time.Time
    Attempts  int
}

func NewCode() (string, error) {
    n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
    if err != nil {
        return "", fmt.Errorf("generate otp: %w", err)
    }
    return fmt.Sprintf("%06d", n.Int64()), nil
}

func (c Challenge) CanVerify(now time.Time, attemptLimit int) bool {
    return c.UsedAt == nil && now.Before(c.ExpiresAt) && c.Attempts < attemptLimit
}
Enter fullscreen mode Exit fullscreen mode

Hash the generated code with a per-challenge salt before persistence, compare in constant time, and never put the plaintext value in logs, traces, analytics, or support screens. Expiry length, code length, and attempt count are security-policy inputs, so this example refuses to smuggle arbitrary values into reusable code. Bind verification to the user, tenant, login purpose, and challenge ID; otherwise a valid code can drift into the wrong transaction.

Rate limits need the same compositional thinking. Limit issuance by account, destination, tenant, device signal, and network source where appropriate, while recognizing that many workers may share a site NAT. Count sends separately from guesses. A single IP ceiling can deny access to an entire crew, but destination-only controls permit repeated messages to become a harassment tool. The practical policy combines scopes, applies a resend cooldown, returns a stable next-eligible time, and reserves capacity for supervised recovery.

No magic here.

Fallback also has a hard boundary: it is not suitable when changing a phone number, changing a recovery mailbox, enrolling an administrator, or authorizing an unusually sensitive action would use the weaker channel to redefine future identity. Use an operator-reviewed recovery process or a stronger factor for those cases. The catch is on-call load; staffed recovery costs attention, while an automatic bypass converts a delivery problem into an authorization problem.

Capacity-plan the shift change, then test the ugly edges

Average traffic hides the event that matters. For an illustrative plan, 600 arrivals spread evenly across 30 minutes mean 20 initial challenges per minute, but the queue must be modeled against the shortest plausible arrival burst, retries, fallback switches, and the expiry window. Those numbers are an example, not a benchmark. Measure the real arrival distribution per site, include headroom for recovery traffic, and reject work predictably before the queue consumes most of a challenge's useful lifetime.

Test the boundary.

I use a simple capacity worksheet: peak challenge requests per minute, retry multiplier, fallback fraction, worker concurrency, downstream throughput, and oldest acceptable queue age. The resulting load test should compress a shift arrival wave, delay one transport, duplicate an enqueue acknowledgement, submit an old code after a channel switch, exhaust one destination's attempt budget, and place many legitimate workers behind one IP. It should also verify that an expired job is discarded rather than delivered late. That last case looks harmless in a messaging dashboard and feels broken at a gate.

Deployment should start with shadow metrics, then one site, then a bounded tenant cohort. Watch redemption latency, expiry rate, fallback frequency, support interventions, and rate-limit denials together; any one of them can improve by pushing pain into another metric. Define rollback as a policy change to the previously observed primary channel, not as disabling controls or extending code validity during an incident.

Cost belongs in the review, but after reliability and abuse resistance. Compare total cost per completed login: transport usage, duplicate work, support time, compliance work, and engineering on-call. A low message rate can lose the buy-versus-build decision when reputation management, regional policy changes, delivery feedback, key rotation, and 24-hour response remain with the platform team.

Draw the buy-versus-build boundary around on-call ownership

Do not ask only which transport API is easiest. Ask who owns each failure domain at 05:30, which evidence they receive, and whether they can change routing without deploying the authentication service.

Boundary Build or operate directly Buy a managed transport Review question
Routing policy Maximum control over site-specific decisions May reduce adapter and delivery operations Can policy change without weakening verification?
Delivery operations Team owns queues, reputation, and response Some transport work moves outside the team Which SLO stops at acceptance, and which reaches redemption?
Data governance Direct control of retention and placement Contract and configuration define the boundary Can audits export the complete challenge timeline?
Portability Generic adapters preserve optionality Provider-specific feedback can improve diagnosis How much code and history move during migration?

A managed service is not suitable when its retention, regional processing, audit export, or incident contract cannot satisfy the tenant's requirements. Direct operation is not suitable when the team cannot staff delivery expertise and on-call response. Stick with a generic transport interface either way — one for enqueueing a delivery command and one for normalized feedback — so authentication policy does not become provider policy.

The final decision rule is intentionally boring: pick the primary channel per site only after it meets the measured redemption objective with enough evidence, keep an independently useful fallback under the same security budget, and retain supervised recovery for the cases where both channels are uncertain. Revisit the choice as conditions change. Reliability at a construction gate is a property of the whole login path, not a label attached to SMS or email.

References

Top comments (0)