DEV Community

GageSterling2648
GageSterling2648

Posted on

Auditable Passwordless Sign-In Using SMS and Email Before Releasing Payment Receipts

Short answer: issue one server-owned login challenge, try SMS first, permit email fallback only through an explicit and audited transition, and release the settled-payment receipt only after that same challenge is verified. Treat channel delivery as evidence about an attempt, never as evidence that the customer authenticated.

The operational rule is blunt: one user intent gets one challenge ID and, eventually, one successful consume event. Express.js can own the HTTP boundary, but it should not own truth in process memory. Persist the challenge, every delivery attempt, the fallback decision, and the final consume result in a transactional store. That record is the compliance evidence when someone asks why a receipt became visible.

One challenge. One winner.

This matters in a fintech receipt flow because the message and the protected action are different jobs. The OTP establishes possession of an enrolled destination. The application still has to confirm that the authenticated subject may view the order whose payment has settled. Don't let a successful code comparison silently stand in for that authorization check.

What failure signal should trigger the fallback?

A timeout in the browser is weak evidence. The tab may be backgrounded, the customer may have changed networks, or the SMS may have arrived while the status callback is still in flight. Automatically sending email whenever a client timer reaches 30 seconds can create two live codes, two user-visible messages, and an audit trail that cannot explain which policy decision authorized the second channel.

Use a server-side transition instead. The user can request fallback, or a delivery result can make the primary attempt ineligible for further waiting, but either path must lock the challenge row and evaluate the same policy. Record an append-only event with the challenge ID, order ID, channel, destination identifier in redacted form, policy version, request ID, and decision timestamp. The challenge row holds the current state; the events explain how it got there.

Be conservative here.

A provider's accepted response means the provider accepted work, not that a human received or read a message. Likewise, email acceptance is not identity proof. Those statuses may inform routing and operations, but only a valid code can move the challenge to verified.

Keep the SMS body short and stable. SMS encoding affects segmentation: GSM-7 messages have different single-message and multipart limits from UCS-2 messages. A surprising character in a localized template can therefore change segment count. Pin the exact template revision in the attempt event, test representative rendered messages, and avoid putting sensitive order details in either channel.

How should passwordless sign-in handle SMS OTP and email fallback?

Model fallback as a state transition, not a second login flow. Hash the OTP with a server-side secret, compare in constant time, set a short expiry according to your risk policy, cap verification attempts, and consume the challenge atomically. The SMS and email adapters receive the same challenge ID and code generation, while the stored policy determines which channel is currently eligible.

The important invariant is verified_at IS NULL during the consume update. Two requests can present the right code at nearly the same instant; one update wins and the other returns an already-consumed result. No drama. This also makes retries safe when a load balancer repeats a request after losing the first response.

Fail closed.

The core below is intentionally outside Express.js. The JavaScript route should validate its request, call an equivalent transactional service, and map domain results to stable HTTP responses. Keeping the state machine independent makes it possible to test the failure cases without starting a web server. All persistence and delivery operations shown here are interfaces because their concrete implementation depends on the store and communication providers you already operate.

package login

import (
    "context"
    "errors"
    "time"
)

type State string
type Channel string

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

    PendingSMS   State = "pending_sms"
    PendingEmail State = "pending_email"
)

var ErrIneligible = errors.New("fallback is not eligible")

type Challenge struct {
    ID            string
    SubjectID     string
    OrderID       string
    State         State
    ExpiresAt     time.Time
    CodeDigest    []byte
    PolicyVersion string
}

type Event struct {
    ChallengeID string
    RequestID   string
    Kind        string
    Channel     Channel
    OccurredAt  time.Time
}

type Tx interface {
    LockChallenge(context.Context, string) (Challenge, error)
    SaveChallenge(context.Context, Challenge) error
    AppendEvent(context.Context, Event) error
    Commit() error
    Rollback() error
}

type Store interface {
    Begin(context.Context) (Tx, error)
}

type Service struct {
    Store Store
    Now   func() time.Time
}

func (s Service) RequestEmailFallback(ctx context.Context, id, requestID string) error {
    tx, err := s.Store.Begin(ctx)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    challenge, err := tx.LockChallenge(ctx, id)
    if err != nil {
        return err
    }
    now := s.Now()
    if challenge.State != PendingSMS || !now.Before(challenge.ExpiresAt) {
        return ErrIneligible
    }

    challenge.State = PendingEmail
    if err := tx.SaveChallenge(ctx, challenge); err != nil {
        return err
    }
    if err := tx.AppendEvent(ctx, Event{
        ChallengeID: id,
        RequestID: requestID,
        Kind: "fallback_authorized",
        Channel: Email,
        OccurredAt: now,
    }); err != nil {
        return err
    }
    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

Committing state and then calling a network sender can leave an authorized attempt unsent if the process exits between those operations. Put the delivery request in a transactional outbox beside the state change, then let a worker claim and retry it with an idempotency key derived from challenge_id + channel + template_revision. The sender adapter should report its provider message identifier into a separate attempt event.

Do not put the raw OTP, full phone number, full email address, or receipt contents in logs. Evidence needs correlation and policy context, not reusable credentials or unnecessary personal data. Retention is a compliance decision for your organization; I'm not sure a generic duration is defensible without the applicable jurisdiction, data classification, and internal legal policy. Resolve that before deployment and encode the result in lifecycle rules instead of a wiki reminder.

Separate authentication evidence from receipt authorization

After verification, bind the resulting session to the subject ID from the challenge. Then query the settled order by both order_id and that subject ID before returning the receipt. A guessed order identifier must never be enough. The authorization event should capture the authenticated subject, order, settlement status observed, policy version, and request ID, while avoiding payment credentials and message content.

The sequence is short, but each boundary has a distinct owner:

  1. The login endpoint creates the challenge and SMS outbox item in one transaction.
  2. A worker sends the SMS and records the provider message identifier and template revision.
  3. An explicit fallback request changes channel eligibility once and creates an email outbox item.
  4. Verification atomically consumes the challenge and creates the authenticated session.
  5. The receipt endpoint checks subject ownership and settled status, then records the access decision.

Email is a fallback channel, not a magic recovery lane. Amazon SES, for example, is an email sending service; application authentication and receipt authorization remain your responsibility. Keep those boundaries visible in code review and incident response.

There is also a product decision hiding inside the word fallback. Automatic channel switching is not suitable when policy requires step-up authentication through two independent factors, because SMS followed by email is still a choice between possession channels rather than proof of both. In that case, require the additional factor your risk team approves. Conversely, stick with SMS-only retry when the account has no previously verified email address, when the email address changed during the current session, or when policy forbids that destination for recovery.

Verification, rollout, and the rollback switch

Test transitions, not just happy-path handlers. Run two concurrent correct-code submissions and assert that exactly one consumes the challenge. Repeat the same fallback request ID and assert that it creates one outbox item. Deliver an SMS callback after email became eligible and confirm that it cannot reverse the state. Advance a fake clock past expiry. Exhaust the attempt counter. Try to access a settled order owned by another subject and verify that no receipt data leaves the authorization boundary.

Then test templates with the data shapes production will render: long names, empty optional fields, international phone formats, and non-GSM characters. SMS segmentation guidance explains why GSM-7 and UCS-2 bodies split differently. Your provider's own test environment should supply the final behavior evidence. Mileage may vary across routes and destinations, so rollout metrics must be sliced by country and carrier where that collection is lawful.

Deploy behind separate controls for challenge creation, user-requested email fallback, and receipt release. Start with internal accounts, then a small cohort. Watch challenge creation, send attempts by channel, verification success, expired challenges, duplicate consume attempts, fallback transitions, outbox age, and receipt authorization denials. Alert on ratios and sustained queue age using a baseline from your own traffic; invented universal thresholds make poor runbooks.

Rollback should disable new email transitions without invalidating already verified sessions or deleting evidence. Let committed outbox work drain unless security directs otherwise. If the release changes challenge schema, use expand-and-contract deployment: add fields first, deploy readers that tolerate both shapes, deploy writers, and remove old fields only after rollback is no longer required.

Stop the rollout if duplicate consumption becomes possible.

The operational decision

Choose this design when one-time password possession is acceptable for the account risk and auditors need a reconstructable chain from login intent to settled-receipt access. Its value comes from explicit states, atomic consumption, and evidence that distinguishes policy decisions from delivery telemetry.

The catch is complexity: an outbox, immutable events, and channel policy are more work than two send calls in an Express.js controller. For a low-risk application with no sensitive receipt and no audit obligation, a managed authentication system may be the more sensible boundary. For a regulated payment workflow, hiding fallback inside a client timer is the wrong trade.

References

Top comments (0)