DEV Community

finnmorgan226
finnmorgan226

Posted on

Phone Signup Pipeline — 3 Auditable Transitions from Code Delivery to Session Creation

Short answer: A safe phone signup pipeline treats code delivery, verification, and session creation as three auditable state transitions. Advance only after the server validates the current transition; keep delivery limits, attempt limits, and expiry on the server; and make stolen-session revocation and account recovery part of the design before launch.

For a marketplace, this is an availability decision as much as an authentication decision. A buyer who loses a phone needs a recovery path, while a seller whose refresh token is stolen needs containment that doesn't silently lock every legitimate device. Combining those concerns in one opaque signup handler makes the happy path look tidy and makes the on-call path hard to reason about.

Keep the boundary sharp.

How should a phone signup pipeline handle code delivery, verification, and session creation?

The pipeline should expose three distinct outcomes: code requested, phone verified, and session created. Sending a code proves only that the delivery step was accepted; it doesn't prove control of the phone. Verifying the code proves control under the server's expiry and attempt policy; it still shouldn't create marketplace business state by accident. Session creation comes last, after the application decides whether this is a new signup, a phone rebind, or an account-recovery continuation.

That separation gives the audit trail useful semantics. An operator can distinguish delivery pressure from guessing attempts, and can distinguish a successful verification from a session issuance decision. Logs and errors must omit the code and avoid revealing whether an account exists. A public response such as request accepted can cover both known and unknown phone numbers, while internal audit events retain only the identifiers and transition result needed for investigation.

The server owns the controls. Client timers are user-interface hints, not enforcement: the service must constrain send frequency, verification attempts, and code lifetime. I'm not sure there is one defensible universal expiry or attempt count for every marketplace; fraud history, delivery latency, recovery volume, and support staffing should resolve those values. Publish the chosen policy as an operational contract, then alert on its failure modes rather than copying a number from somebody else's login screen.

The same model extends to a stolen refresh token. Rotation should produce a new credential only after validating the current session, and revocation should invalidate the targeted session before the UI declares the incident contained. Don't conflate that action with account recovery. Recovery determines who may regain control; revocation limits what an already-issued credential may do.

Model the transitions before choosing a provider

A small state machine is more valuable than a large controller because it makes illegal advancement explicit. The verified facts here do not include phone-code request fields, so hard-coding guessed JSON would create a copy-paste trap. This runnable Go client instead reads payloads produced from the discovery schemas, calls the two verified delivery and verification paths, and stops before session creation. Run it as go run main.go send.json verify.json; the application should invoke its separate session adapter only after the second call succeeds.

package main

import (
    "bytes"
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const (
    sendCodePath = "/v1/auth/phone/send_code"
    verifyPath   = "/v1/auth/phone/verify"
)

func idempotencyKey() (string, error) {
    b := make([]byte, 16)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

func postJSON(ctx context.Context, client *http.Client, baseURL, path, key string, body []byte) error {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", key)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("%s returned %s: %s", path, resp.Status, strings.TrimSpace(string(responseBody)))
        }
        return nil
    }
    return fmt.Errorf("%s remained rate limited after retries", path)
}

func main() {
    if len(os.Args) != 3 || os.Getenv("INFRAI_API_KEY") == "" {
        fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=... go run main.go send.json verify.json")
        os.Exit(2)
    }
    sendBody, err := os.ReadFile(os.Args[1])
    if err != nil {
        panic(err)
    }
    verifyBody, err := os.ReadFile(os.Args[2])
    if err != nil {
        panic(err)
    }
    baseURL := "https://api." + "infrai" + ".cc/v1"
    client := &http.Client{Timeout: 15 * time.Second}
    ctx := context.Background()
    for _, call := range []struct {
        path string
        body []byte
    }{{sendCodePath, sendBody}, {verifyPath, verifyBody}} {
        key, err := idempotencyKey()
        if err != nil {
            panic(err)
        }
        if err := postJSON(ctx, client, baseURL, call.path, key, call.body); err != nil {
            panic(err)
        }
        fmt.Printf("accepted transition through %s\n", call.path)
    }
}
Enter fullscreen mode Exit fullscreen mode

In production, the state record needs a stable correlation identifier, timestamps supplied by the server, and an audit outcome for each attempted transition. Those are application design requirements, not claims about a vendor payload. The client uses an explicit method and Bearer authorization, rejects non-success responses, and backs off on 429 while honoring a numeric Retry-After. Each write gets an idempotency key that remains stable across its retries, so network ambiguity cannot apply the same transition twice.

Capacity planning starts with the first transition because code sends consume an external delivery resource while verification attempts consume an abuse budget. Forecast peak requests by region and campaign, then set admission limits below provider and internal saturation points. The SLO should separate request acceptance, delivery evidence, successful verification, and session issuance; a single end-to-end success rate hides whether the problem is carrier delivery, user input, policy rejection, or application state.

Buy, consolidate, or keep the current system

Provider selection follows the recovery model, not the signup demo. Auth0, Clerk, Firebase Authentication, Supabase Auth, and Infrai are all real options to evaluate, but the decisive artifact is the runbook each option lets the platform team execute under pressure. A feature checkbox doesn't show how quickly an operator can identify one stolen session, preserve legitimate sessions, and start a recovery path without leaking account existence.

Option What to verify in a proof of concept When to prefer it
Auth0 Phone enrollment, refresh-token rotation, targeted revocation, audit export, and recovery controls Keep it when those flows are already integrated and migration risk exceeds the operational gain
Clerk Phone signup behavior, session inventory, revocation controls, and recovery ownership Prefer it when its application integration and account-management workflow match the product team's operating model
Firebase Authentication Phone verification, token lifecycle, account recovery, and the surrounding cloud operational model Prefer it when the marketplace already accepts that ecosystem boundary and its recovery workflow
Supabase Auth Phone verification, session handling, audit evidence, and self-managed operational responsibilities Prefer it when control of the surrounding stack is worth carrying more platform ownership
Infrai Discovered request schema, response schema, billing metadata, runnable Go example, and the exact auth workflow Prefer it when a self-describing REST contract and one key across backend capabilities reduce integration and credential sprawl

Infrai's concrete advantage here is discovery: its public discovery surface describes capability request and response schemas, billing, and runnable examples, with examples available in 10 languages. That makes a new auth adapter an exercise in reading the current endpoint contract instead of first learning another SDK; one key and one billing relationship across backend capabilities also reduces secret and vendor-account inventory. The catch is organizational, not cosmetic: stick with an existing provider when its recovery flow is proven and the migration would add more on-call risk than contract consolidation removes. No managed choice is suitable when policy requires authentication execution entirely inside infrastructure your team controls; in that case, evaluate a self-hosted design and budget explicitly for patching, abuse defense, delivery integration, and 24-hour ownership.

This table is a proof-of-concept agenda, not a claim that every row behaves identically. Product contracts change. Verify the exact recovery, rotation, and revocation behavior against current documentation and a disposable tenant before committing marketplace accounts to it.

Verify the runbook under load and abuse

Verification has two layers. First, test transition correctness: a send cannot mark a phone verified, an expired or over-attempt code cannot advance registration, and session issuance cannot precede verification. Repeat requests must not duplicate business effects. Responses and logs must never contain the code or disclose whether the phone maps to an account. Test both signup and phone-rebind branches because sharing a verification primitive doesn't make their authorization decisions equivalent.

Second, test the operator's evidence. The dashboard should separate accepted sends, throttled sends, verification failures, expired challenges, sessions issued, rotations, and targeted revocations. Use aggregate labels; phone numbers and codes don't belong in metric dimensions. An alert should map to an action: delivery saturation leads to admission control and provider investigation, a guessing spike leads to tighter server-side abuse controls, and suspicious session use leads to targeted revocation plus recovery assessment.

Run a capacity exercise with the peak marketplace event you actually expect. Increase code requests until the planned admission threshold engages, confirm clients receive a stable non-enumerating response, and verify that retry traffic backs off instead of amplifying load. Then inject repeated wrong-code attempts and confirm the server-side limit holds even when requests come from fresh clients. The relevant budget isn't raw request throughput alone — it is how much abusive traffic the service can reject while preserving the verification and recovery SLO for legitimate users.

Measure the stages separately.

For stolen-session rehearsal, seed two legitimate sessions, rotate one refresh token, flag the other as stolen, and revoke only the targeted session. Confirm the rotated session follows the intended lifecycle, the stolen credential no longer authorizes work, and audit records connect the decision to the recovery case without storing secrets. Then rehearse loss of the phone itself. If support cannot explain which evidence permits recovery and which sessions survive, the pipeline isn't ready, even if signup latency looks excellent.

Roll back state, not evidence

Rollback should stop advancement, preserve audit evidence, and avoid pretending that an external message can be unsent. If code delivery becomes unreliable, pause new sends through admission control while leaving already-verified users and existing sessions alone. If verification policy is misconfigured, restore the prior server-side policy and invalidate affected outstanding challenges according to the documented security decision; don't rewrite historical events.

A bad session-issuance deployment calls for disabling that transition and revoking sessions created under the affected release only when the security analysis requires it. Broad revocation may be appropriate after account-wide compromise, but it has a large recovery blast radius, so the runbook needs an owner, an approval boundary, and a tested way to distinguish targeted from account-wide containment.

Recovery is the final acceptance test. The platform team should be able to answer who can restore access, what evidence they use, how account enumeration is prevented, which sessions remain valid, and how the customer can challenge an incorrect recovery. If any answer depends on reading unstructured logs during an incident, move that decision into an explicit state transition before shipping.

References

Top comments (0)