DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Email vs Phone Verification in SaaS Login: 4 Recovery Gates Against Abuse

Short answer: use email as the primary proof for Google and GitHub sign-in, keep phone as a step-up signal, and design recovery so losing either channel does not strand the account. Delivery risk is an availability problem; bot resistance is an abuse-budget problem. Treat both as SLOs with different owners.

The trigger is usually a queue graph, not a dramatic incident. Email delivery climbs from 30 seconds to 12 minutes in one region, or a phone carrier silently filters a burst of codes. Meanwhile a signup bot rotates cheap numbers and creates thousands of unverified identities. If the only recovery path is the same channel that failed, support becomes the fallback authentication system.

What should Email and Phone Verification protect in a social-login flow?

Start by separating identity linking from contact proof. Google and GitHub return an identity assertion; your service should validate the issuer, audience, signature, nonce, and redirect URI before it links an account. A verified email from an identity provider is useful evidence, but it is not a universal promise that the mailbox is reachable today. Store the provider subject as the stable key, then attach email and phone records with independent verification timestamps.

For a B2B SaaS tenant, the abuse boundary is usually the invitation and organization-creation actions, not the login button. Require a verified email before creating an organization, rate-limit phone challenges by account, IP range, ASN, and device fingerprint, and make every limit observable. A challenge endpoint should return the same public response for an existing and a new account; otherwise attackers can turn verification into an account-enumeration oracle.

The policy I use is deliberately boring:

Signal Good at Weak at Control
Email link or code Reachability, audit trail, enterprise onboarding Mailbox delay, forwarding, compromised inbox Short-lived token, single use, resend budget
Phone code Step-up friction, recovery diversity SIM swap, recycled numbers, carrier filtering Attempt limit, cooldown, risk scoring
Google/GitHub assertion Fast login, stable provider subject Provider outage or account takeover OIDC/OAuth validation, re-auth for sensitive actions

This is a capacity plan as much as an auth design. Set an availability target for challenge creation, a delivery latency SLO by region, and a separate abuse SLO such as blocked automated attempts per hour. Combining them into one success-rate metric hides the trade-off: a strict throttle can improve abuse numbers while making legitimate recovery fail.

How do delivery risk, recovery paths, and account continuity interact?

Model verification as a small state machine, not a boolean column. unverified, pending, verified, and revoked states let you expire a challenge without deleting the last known-good contact. Keep a hash of the challenge, its creation time, purpose, and a monotonic attempt counter. Never log the raw code.

Here is a compact Go handler for issuing an email challenge. The transport is an interface so the same policy can be tested with a fake queue and deployed with any provider.

package verify

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

type Sender interface { Send(to, body string) error }

type Challenge struct {
        Digest    string
        ExpiresAt time.Time
        Attempts  int
        Purpose   string
}

func IssueEmail(sender Sender, address, purpose string, now time.Time) (Challenge, error) {
    var raw [6]byte
    if _, err := rand.Read(raw[:]); err != nil { return Challenge{}, err }
    code := fmt.Sprintf("%06d", int(raw[0])<<16|int(raw[1])<<8|int(raw[2])%1000000)
    h := sha256.Sum256([]byte(code))
    c := Challenge{Digest: hex.EncodeToString(h[:]), ExpiresAt: now.Add(10 * time.Minute), Purpose: purpose}
    if err := sender.Send(address, "Your verification code is "+code); err != nil { return Challenge{}, err }
    return c, nil
}
Enter fullscreen mode Exit fullscreen mode

The exact code generator is less important than the invariants: constant-time comparison, one-use consumption, a ten-minute maximum lifetime (or a shorter value justified by your threat model), and a resend cooldown that is tracked separately from failed verification attempts. When delivery is delayed, show a neutral status and preserve the original challenge until it expires; issuing a new code on every click creates a denial-of-service lever against the user.

Recovery must have two independent paths. A user who still controls Google or GitHub can re-authenticate there and add a new email or phone. A user who lost that provider needs an organization-admin recovery flow with documented evidence, a waiting period, and an audit event. Do not let a support agent replace a contact field from an unverified email request. Your mileage may vary on the waiting period; measure fraud review capacity before choosing it.

What does a safe verification runbook measure and roll back?

Instrument each hop with a correlation ID: challenge accepted, message queued, provider accepted, delivered or bounced, code submitted, and account state changed. Alert on the difference between provider acceptance and user completion, split by country and channel. A sudden completion drop with normal queue latency points to filtering or a broken template; a queue spike points to capacity or a bot campaign.

Run synthetic checks against a mailbox and test number that you own, but keep them outside production user limits. During an incident, first freeze organization creation for unverified identities, then widen neither code lifetime nor attempt counts. Roll back the newest template or routing change, drain the queue, and communicate a recovery option that uses an already verified provider subject. Record the decision and expiry time; temporary exceptions tend to become permanent attack surface.

The buy-vs-build boundary is operational:

Build yourself Buy or delegate
State machine, linking rules, risk limits, audit events Message delivery, carrier and mailbox routing
Recovery approvals and tenant policy Regional redundancy and deliverability analytics
Metrics, SLOs, and rollback switches Compliance attestations you cannot staff

Stay with a self-hosted sender when traffic is predictable, data residency is strict, and your team can operate reputation and carrier relationships. Delegate delivery when on-call coverage is thin or your customers span regions, but keep the policy and account state in your service so a provider change does not rewrite identity semantics.

Verification checklist for the next release

Test expired, replayed, and concurrent codes; provider subject collisions; email case normalization; recycled phone numbers; and a user who loses both channels. Exercise the Google and GitHub callback with invalid nonce and redirect values. Verify that logs, analytics, and support tooling never expose a code or a complete contact address.

Ship behind a tenant-scoped flag. Compare completion latency, false-positive blocks, recovery volume, and challenge cost for one cohort. If the abuse SLO worsens, disable organization creation for fresh identities while leaving existing-account login available. That is a rollback with a clear blast radius, not a panic switch.

References

Top comments (0)