DEV Community

SebastianCole3681
SebastianCole3681

Posted on

Audit-Ready SaaS Login Recovery (Password Email Links or SMS Codes)

Short answer: default to a single-use password reset email link for SaaS login recovery, including US and EU property-management accounts, and add SMS OTP only when the tenant or property manager has a verified phone number, the risk justifies a second channel, and the audit trail can prove every state transition without storing the secret itself.

That recommendation isn't based on a per-message price. It follows from the control surface. Email links need token issuance, delivery, redemption, expiry, and domain authentication; SMS adds phone-number ownership, character encoding and segmentation, retry behavior, and a second class of support cases. A property platform that already sends an order receipt after a payment settles should reuse the same event and evidence model, but it shouldn't confuse a successfully queued receipt with proof that a recovery message reached the right human.

Keep the rule blunt: one recovery state machine, two optional delivery adapters, no channel-specific account mutations.

What should a SaaS retain when choosing a password reset email API or SMS OTP?

Start with the evidence you can retain and explain, not with whichever API produces the shortest demo. For a property-management SaaS, the relevant sequence may begin when a resident pays an order, the payment reaches settled, and the platform sends a receipt. Months later, the resident may use the same email address to recover a login. Those are separate purposes, yet both need an immutable correlation between a business event, a destination selected under a documented rule, a template version, an attempted delivery, and the resulting application state. This evidence model is the fixed point; channel selection comes later.

Email is the narrower default when email is already the account identifier. A reset link can carry a random, single-use bearer value while the database retains only its hash; the application can expire it, consume it atomically, and revoke all outstanding values after a successful password change. DKIM supplies a standardized way for a signing domain to take responsibility for a message by attaching a domain-level signature. It doesn't prove that the intended person read the message, so don't label a DKIM pass as user verification.

SMS OTP is reasonable when the phone number was verified before the recovery attempt and the risk policy calls for an independent possession check. The catch is that SMS content has transport-level size behavior: GSM-7 and UCS-2 use different single-message and multipart limits, and concatenation changes the available characters per segment. A localized EU template can therefore consume a different number of segments than a terse US template. Capacity planning has to use encoded segments, not logical messages, or the budget and rate model will be fiction.

Neither channel turns a weak identity proof into a strong one.

For simplicity, count the states and operators rather than the lines of integration code. Email normally wins when the team controls the account mailbox workflow and can authenticate its sending domain. SMS can be the smaller user interaction for a person holding a verified phone, but the service still owns attempt limits, expiry, replay prevention, destination changes, and message segmentation. I'm not sure which channel will be cheaper for a particular traffic mix without its country distribution, encoding, retry rate, and support load; a one-week sample of encoded segments and recovery outcomes would resolve that uncertainty better than a public rate card.

Trace the payment-to-receipt integration boundary

The dangerous failure mode is an ambiguous transition. Suppose payment ord_78142 settles, a receipt request is accepted, and the resident later disputes whether the destination on file was correct. The useful record is not a copied email body. It is a compact chain: payment.settled caused receipt.requested; policy version receipt-v4 selected a destination reference; template digest 8f21... was rendered; the delivery adapter accepted correlation ID msg_20491; and no later event silently rewrote any of those facts. Recovery should follow the same shape with recovery.requested, challenge.issued, challenge.redeemed, and credential.changed.

This is where teams often over-collect. Don't put the reset token, SMS code, full receipt, password, or unmasked destination into a general application log. Store a destination reference or a deliberately masked form, a hash of the challenge, timestamps, a policy version, and stable correlation IDs. Restrict access and define retention according to the actual compliance program; “keep everything forever” is not an audit strategy.

Set SLOs on outcomes the system owns. A useful recovery SLO can measure the share of eligible requests that reach challenge.issued within a target window and the share of valid, unexpired challenges that are redeemed without an internal conflict. Delivery-provider acceptance is a dependency signal. User completion is a product signal. Mixing all three into one success counter hides whether the problem is capacity, policy, transport, or a user who abandoned the flow.

Watch the denominator.

Rate limits must exist at several keys: account, destination reference, IP or risk bucket, and global channel capacity. The exact thresholds are local risk decisions, not universal constants. Model a burst from a property portfolio import as well as a hostile loop, because both can create a sharp delivery spike while demanding different responses. For the receipt path, use the settled-payment event ID as the idempotency key; for recovery, use a server-generated request ID and allow only one atomic transition from an unused challenge to a consumed challenge. A retry may repeat delivery work, but it must never repeat the business mutation.

Implement one state machine and keep transport outside it

The core should decide what may happen. Adapters should only deliver the already-authorized message and return a correlation ID. This Go sketch leaves vendor calls behind interfaces and records evidence before and after delivery; it also hashes the email-link secret before persistence. An SMS adapter can issue a separately generated OTP under the same state contract, but it must never derive a short SMS code by truncating this URL token.

package recovery

import (
    "context"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "errors"
    "time"
)

type Channel string

const (
    EmailLink Channel = "email_link"
    SMSOTP    Channel = "sms_otp"
)

type Delivery struct {
    RequestID      string
    DestinationRef string
    Template       string
    Secret         string
}

type Sender interface {
    Send(ctx context.Context, delivery Delivery) (correlationID string, err error)
}

type Evidence struct {
    RequestID      string
    Event          string
    Channel        Channel
    DestinationRef string
    Template       string
    ChallengeHash  [32]byte
    CorrelationID  string
    At             time.Time
}

type EvidenceSink interface {
    Append(ctx context.Context, event Evidence) error
}

type Issuer struct {
    Email Sender
    Audit EvidenceSink
    Now   func() time.Time
}

func (i Issuer) IssueEmailLink(
    ctx context.Context,
    requestID string,
    destinationRef string,
) (string, error) {
    secretBytes := make([]byte, 32)
    if _, err := rand.Read(secretBytes); err != nil {
        return "", err
    }
    secret := base64.RawURLEncoding.EncodeToString(secretBytes)
    hash := sha256.Sum256([]byte(secret))
    issued := Evidence{
        RequestID:      requestID,
        Event:          "challenge.issued",
        Channel:        EmailLink,
        DestinationRef: destinationRef,
        Template:       "password-reset-v3",
        ChallengeHash:  hash,
        At:             i.Now().UTC(),
    }
    if err := i.Audit.Append(ctx, issued); err != nil {
        return "", err
    }

    correlationID, err := i.Email.Send(ctx, Delivery{
        RequestID:      requestID,
        DestinationRef: destinationRef,
        Template:       issued.Template,
        Secret:         secret,
    })
    if err != nil {
        return "", err
    }
    if correlationID == "" {
        return "", errors.New("delivery correlation ID is required")
    }
    issued.Event = "delivery.accepted"
    issued.CorrelationID = correlationID
    issued.At = i.Now().UTC()
    if err := i.Audit.Append(ctx, issued); err != nil {
        return "", err
    }
    return secret, nil
}
Enter fullscreen mode Exit fullscreen mode

The longer paragraph matters here because the transaction boundary is easy to misread: appending challenge.issued before calling the adapter makes the authorization decision durable, while appending delivery.accepted afterward preserves the dependency correlation, yet a production implementation still needs an outbox or equivalent replayable handoff so a process exit between those operations cannot strand authorized work. The sender should consume that outbox idempotently. Redemption belongs in a different transaction that compares the presented hash, checks purpose and expiry, marks the challenge consumed with a conditional write, changes the credential, and revokes sibling challenges. Receipt dispatch uses the same outbox discipline but a different purpose and payload policy. Shared mechanics are good; shared authorization rules are not.

Don't log secret.

Capacity planning can now start from honest units: recovery requests, receipt events, adapter attempts, email bytes, SMS encoded segments, and evidence writes. Forecast the peak five-minute arrival rate, not only the monthly average, then reserve headroom for retries and a portfolio-wide payment batch. The capacity limit must fail closed for new recovery challenges while leaving credential redemption available; otherwise a delivery surge can lock out users who already possess valid challenges.

Evaluate channel policy with a recovery harness

Before deployment, test the state transitions as a matrix: repeated request IDs, concurrent redemption, expired challenges, destination changes, delayed delivery acceptance, adapter timeouts, and a settled-payment event delivered more than once. Test rendered SMS using its actual character encoding and segment count. Test email authentication and preserve the signing result as transport metadata, without treating it as identity proof. Then run a canary by policy version so every issued challenge says which rule selected its channel.

The buy-versus-build decision is less dramatic once ownership is explicit:

Option Team owns Best fit Not suitable when
Managed delivery, internal state Recovery policy, evidence, tokens, outbox, templates, adapter contract Small platform team that wants transport operations outside its on-call boundary Provider-specific callbacks have leaked into credential state transitions
Self-hosted transport and state All of the above plus reputation, queues, carrier or mailbox behavior, and capacity Organization with specialized operators and a hard control requirement The on-call team cannot staff transport expertise
Managed primary plus second adapter Channel policy, portable evidence, failover qualification, two integrations Recovery risk justifies channel diversity The second path isn't exercised and observed continuously

My default would be managed transport behind a narrow internal interface, with all authorization and evidence kept in the application boundary. That choice reduces the platform team's transport burden without pretending lock-in disappears: templates, delivery metadata, callback meanings, and destination policy can still couple the system to an adapter. Keep normalized internal events and retain raw provider metadata only where the evidence policy requires it.

Rollback must never delete or rewrite evidence. Roll back the policy version that selects SMS, disable new SMS challenges, or return new requests to email links; continue accepting previously issued, valid challenges until their normal expiry unless the security response requires revocation. Preserve both the superseded policy version and its events. For a receipt deployment, stop new dispatch from the outbox while retaining settled-payment events and idempotency keys, then resume from the durable cursor.

The final go/no-go check is concrete: can an operator trace one payment receipt and one recovery attempt from originating event to terminal state, explain every retry, show which policy and template ran, and do so without seeing a credential or challenge secret? If yes, the delivery channel is an adapter choice. If no, changing from email to SMS merely changes the shape of the missing evidence.

References

Top comments (0)