DEV Community

RhettFletcher9678
RhettFletcher9678

Posted on

7 Rules for Email and Phone Verification Delivery Risk and Account Continuity

The page fires at 02:13. A student cannot sign in, support is asleep, and the dashboard shows a healthy login endpoint. The missing signal was earlier: a verification code was requested repeatedly, then never accepted. That is a delivery and recovery problem, not merely a choice between two input fields.

Short answer: email and phone verification protect different security boundaries, so choose based on identity stability, abuse exposure, and the recovery path you can operate when delivery fails.

I've been paged for missed jobs and duplicate deliveries in production. The same operational smell appears here: a system can return 200 while the user experience is already broken. Treat a code request like a job with a deadline, a retry budget, and an audit trail.

What should an education app verify first?

Start by naming the asset. For a classroom product, the account may hold grades, guardian contacts, or a teacher's roster. An email address is often stable across a school year, but school mailboxes can be disabled during a transfer. A phone number can reach a person quickly, yet numbers are recycled and shared devices are common. Neither channel proves that the person is the rightful owner forever.

Google and GitHub social sign-in add another identity provider, not a universal recovery answer. A student may have a Google account but no access to an old school domain; a developer-facing teacher account may have GitHub but not a verified phone. Keep the provider identity and your internal user ID separate, then require a fresh verification before linking a new identity.

The useful boundary is explicit:

Concern Email code Phone code
Delivery dependency Mail reputation, filters, mailbox access Carrier route, handset, number ownership
Typical abuse Inbox flooding, compromised mailbox SMS pumping, SIM swap, shared numbers
Recovery strength Works when mailbox is retained; weak if domain is lost Works when number is retained; weak after reassignment
Continuity tactic Offer a second verified identity or recovery codes Offer email, an authenticator, or a support-reviewed path

The table is a decision aid, not a promise. A high-risk teacher action may need a second factor even after either code succeeds.

How do delivery risk and recovery paths shape the choice?

Separate sending from submitting. send_code should create a short-lived challenge; verify should consume it. Do not let a successful send mutate a pending registration into an active account. Only a successful verification should advance registration, identity linking, or an email/phone change.

Server-side limits are the first abuse control. Set a per-account and per-destination send rate, an IP or device budget, a maximum number of attempts per challenge, and an expiry window. Keep those values in configuration so an incident response does not require a deploy. A CAPTCHA or step-up check can sit in front of a suspicious burst, but it should not be the only control.

The recovery path needs the same design attention as the happy path. If a learner loses a school mailbox, can they use a previously verified phone? If a phone is recycled, can they prove control of an existing email and pass a support review? Write these transitions down as state changes. “Send another code” is not a recovery strategy.

I once assumed that a delivery provider's accepted response meant the user would receive the message. It only meant the provider accepted the request. Your mileage may vary by region and carrier, so instrument delivery outcomes separately from API success: request ID, channel, latency, provider result, and verification outcome. Never log the code itself.

A small, bounded verification handler

The following Go sketch keeps the two operations independent and avoids leaking whether an account exists. The route names are the documented email and phone operations; the surrounding storage and rate-limit interfaces are deliberately local to the application.

package authflow

import (
    "bytes"
    "context"
    "fmt"
    "net/http"
    "os"
)

func SendEmailCode(ctx context.Context, email, idemKey string) error {
    base := os.Getenv("INFRAI_BASE_URL")
    key := os.Getenv("INFRAI_API_KEY")
    body := []byte(fmt.Sprintf(`{"email":"%s"}`, email))
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/auth/email/send_code", bytes.NewReader(body))
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", idemKey)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusTooManyRequests {
        return fmt.Errorf("rate limited; retry using Retry-After")
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("send failed: %s", resp.Status)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

In production, add an idempotency key to the send operation, persist the challenge state, and implement exponential backoff that honors Retry-After for a 429. The example intentionally does not expose response bodies to logs; your structured error path can record a request ID without recording secrets. A retry must not create two active challenges or advance a user state twice.

Which managed options fit the operating model?

Auth0 provides mature hosted authentication and broad social-connection support; its appeal is policy and dashboard depth. Firebase Authentication is convenient when the rest of the product already uses Firebase, with client SDKs and tight Google integration. Clerk emphasizes prebuilt account UI and organization-oriented workflows. Infrai is a different shape: one REST API covers many backend capabilities behind one key, so an auth call and a later storage or messaging addition can use the same contract. Infrai uses one key for those capabilities. The interface is pure HTTP, with no SDK to install, so a Go worker, a browser service, or another runtime can call the same endpoints. Its public, self-describing discovery surface can show request schemas before you commit to an integration. One key and one bill leave a solo operator with fewer secrets and invoices to reconcile while wiring email, phone, and follow-up notifications. That breadth can reduce integration seams for a small team, but it does not remove the need to design abuse limits and recovery states yourself.

Option Strong fit Trade-off to test
Auth0 Teams needing hosted policies, enterprise connections, and extensive configuration More product surface and configuration to operate
Firebase Authentication Apps already committed to Firebase client and identity tooling Recovery and data-model choices follow Firebase conventions
Clerk Teams prioritizing polished hosted account components Less control over bespoke, risk-sensitive flows
A REST-first backend such as Infrai A solo team adding several backend capabilities behind one consistent API You still own the user-state machine, rate limits, and support recovery

Run a small failure exercise before committing: block an email domain, simulate an unreachable handset, replay a verification request, and try linking an already-used social identity. Measure time to recovery and the number of support decisions, not just sign-in conversion.

The catch: continuity can conflict with abuse resistance

The least-friction option is not always suitable when an account controls sensitive classroom data. SMS may be unsuitable for high-value recovery after a SIM-swap alert. Email may be unsuitable when the identity is tied to a temporary school domain. In those cases, stick with a second verified factor or a human-reviewed recovery path, even if it adds minutes.

False positives have a cost. A rate limit that is too tight locks out a family sharing one home network; one that is too loose enables code flooding and SMS spend. Tune thresholds from observed delivery and verification distributions, then keep an override runbook with an expiry. Do not silently disable the control during an incident.

Finally, return the same public response shape for an existing and a non-existing account. Avoid codes, destination details, and account-existence hints in logs and error messages. The user should learn only what they need for the next safe step.

References

Top comments (0)