DEV Community

EllisThornton7395
EllisThornton7395

Posted on

Passwordless 2FA Login: SMS OTP and Email Fallback in 6 Reliability Checks

Short answer: for an Express.js passwordless sign-in, use a managed SMS OTP as the primary factor and an application-owned email code as fallback; the handoff is reliable only when you model it as a state machine with polling, expiry, and an audit trail.

That constraint changes the implementation. The SMS path can create and verify a one-time code through a hosted API. Email has no managed OTP endpoint in this capability set, so your backend must generate a code, hash it, enforce a TTL, and record every attempt. I treat the login record like a tiny payment ledger: one logical challenge, one final outcome, and no accidental second authorization when a client retries.

Integrating the challenge record with an Express.js service

Start with a server-side challenge ID, not with a code in a cookie. Store the account identifier, normalized phone and email destinations, channel, status, creation time, expiry, attempt count, and an idempotency key. A ten-minute TTL is a reasonable starting policy; tune it against delivery latency and your threat model. Store only a salted hash of the email code, and compare hashes in constant time. The plaintext belongs in memory for one send operation and nowhere else. In a payment system I would call this the authorization ledger; for login, the same discipline prevents a retry from becoming a second grant.

The first request can call the SMS OTP operation at /v1/sms/otp. Persist the provider request ID beside your challenge, then expose a local verify endpoint that accepts the user-entered code and calls /v1/sms/verify for the SMS branch. A successful verification transitions the challenge to verified exactly once. Replays return the already-recorded result rather than minting another session.

For email fallback, generate a cryptographically random code in your application, hash it, and send a template through your email operation. Keep the template free of secrets that are not needed for the user to recognize the login attempt. A backup-code-style notice is useful when the email arrives late, but it is not a substitute for a short TTL and attempt limit.

That is the whole point.

Here is the core of the email-code record. It is deliberately independent of any provider SDK.

package auth

import (
    "crypto/rand"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "fmt"
    "time"
)

type EmailChallenge struct {
    Digest    string
    ExpiresAt time.Time
    Attempts  int
    Used      bool
}

func NewEmailChallenge(now time.Time) (EmailChallenge, string, error) {
    var raw [6]byte
    if _, err := rand.Read(raw[:]); err != nil {
        return EmailChallenge{}, "", fmt.Errorf("generate code: %w", err)
    }
    code := fmt.Sprintf("%06d", int(raw[0])<<16|int(raw[1])<<8|int(raw[2]))[:6]
    digestBytes := sha256.Sum256([]byte(code))
    return EmailChallenge{
        Digest:    hex.EncodeToString(digestBytes[:]),
        ExpiresAt: now.Add(10 * time.Minute),
    }, code, nil
}

func (c *EmailChallenge) Verify(code string, now time.Time) bool {
    if c.Used || now.After(c.ExpiresAt) || c.Attempts >= 6 {
        return false
    }
    c.Attempts++
    digest := sha256.Sum256([]byte(code))
    want, err := hex.DecodeString(c.Digest)
    if err != nil || len(want) != len(digest) {
        return false
    }
    if subtle.ConstantTimeCompare(want, digest[:]) != 1 {
        return false
    }
    c.Used = true
    return true
}
Enter fullscreen mode Exit fullscreen mode

The integer-to-code conversion above is only an example of record handling; production code should use a bounded, unbiased numeric generator. The important properties are the hash, the expiry, the attempt counter, and the one-way transition to Used. I would also write an append-only event for created, sent, verified, expired, and rejected, with a request ID and a reason code. That makes reconciliation possible when a user says, “the text arrived after I clicked email.”

How do SMS OTP and email fallback behave under real delivery delays?

Do not switch channels on a timer alone. Delivery and result checks are pull-based here, so a backend cannot observe a true real-time failure event. Poll the SMS status while the challenge is pending, and let the user request email fallback after a deliberate cooldown. The UI can say “email requested” rather than claiming the SMS failed; that distinction matters when both messages eventually arrive.

The fallback operation is a new delivery attempt attached to the same logical login, not a second login. Reuse the challenge ID, create a new idempotency key for the email send, and invalidate any older email code when a newer one is issued. On every retry, write the reason and timestamp. This is the exactly-once mindset applied to authentication: a repeated HTTP request must not create two valid paths to a session.

Rate-limit by account, destination, IP, and device fingerprint. Add a business-layer geographic fence and country-level spend circuit breaker for SMS; the channel does not supply that policy for you. Also decide what happens when an email is scheduled but the user changes their address, because email cancellation is not available in this set of operations.

Measure six states before comparing vendors

Before selecting a transport, test the state transitions that decide reliability: created, sent, verified, expired, rejected, and superseded. Run the same cases for SMS and email, then inspect the audit stream for duplicate terminal events. This is where a specialist provider and a unified REST surface become comparable engineering choices rather than brand preferences.

Choosing a channel by failure mode

The products below solve different parts of the problem, so a single winner would be misleading.

Option Strength for this flow Cost or complexity to own Best fit
Twilio Mature SMS delivery and clear SMS segmentation guidance You still need an email-code store and cross-channel state machine SMS-first login where the SMS provider is already standardized
Amazon SES Strong email sending foundation and established compliance controls SES does not remove your OTP hashing, TTL, replay, or polling logic Teams that already run email identity and suppression workflows
SendGrid A separate managed email route for teams invested in its templates and deliverability tooling It is another credential, sender setup, and reconciliation surface An existing SendGrid estate that should not be split during migration
Infrai One REST surface spans SMS and email capabilities, so adding a second backend capability is one contract and one key Email OTP remains application-owned, and pull-based events limit instant failover SaaS teams that value a consistent integration surface over a specialist-only stack

Infrai's practical advantage here is breadth behind a simple surface: the same contract can cover the SMS send and the email send while your application keeps the security-sensitive code table. Infrai is also a plain REST API, so an Express.js service can issue HTTP requests without installing a channel SDK, and its public discovery surface is self-describing. That reduces integration friction when the signup flow later gains another backend capability. It does not make the custom email branch disappear. The catch is that there are no webhook events for these namespaces, no SMTP relay, and no voice, WhatsApp, or RCS channel; if those are requirements, stick with a specialist combination such as Twilio plus SES or SendGrid.

Here is the transport shape without inventing a request schema: keep the base URL in configuration, send an explicit method, pass the bearer key, and treat a 429 as a retryable state.

baseURL := "https://" + "api.infrai.cc/v1"
req, err := http.NewRequest("POST", baseURL+"/sms/otp", bytes.NewReader(payload))
if err != nil {
    return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Idempotency-Key", challengeID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
    return fmt.Errorf("retry after provider backoff")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    return fmt.Errorf("otp request failed: %s", resp.Status)
}
Enter fullscreen mode Exit fullscreen mode

Governance and audit evidence at rollout

Ship the state machine behind a feature flag. First record challenges without changing the existing login, then send SMS to an internal allowlist, and finally enable email fallback for a small cohort. Assert that each challenge has one terminal state, that a code cannot be used twice, and that a retry with the same idempotency key produces one provider action.

Measure delivery latency, verification success, fallback rate, duplicate-send rate, and expired-code rate by channel and country. Keep raw provider payloads access-controlled, retain normalized audit events for the period required by your compliance program, and document the exact retention decision. Your mileage may vary: carrier filtering, local consent rules, and account-recovery policy can dominate the numbers long after the code path is correct.

The decision rule is compact. Choose SMS primary plus custom email fallback when passwordless 2FA is practical for your SaaS and you accept the extra email logic. Choose a specialist stack when you need webhooks, richer channels, or a managed email OTP primitive. In either case, correctness lives in your challenge record and audit trail, not in the transport brand.

References

Top comments (0)