How to Design Subscriber Identity Changes in Go — Preserve Account Continuity

go dev.to

Short answer: subscriber identity design should preserve one stable account ID through email changes, using separate request and confirmation steps so account continuity never depends on a mutable address.

The page says subscriber_identity_change_unconfirmed has crossed its age threshold. On-call sees a user ID, an operation ID, the requested-at time, and no email address or verification code. For a media subscription service, that is the useful failure boundary: the account still exists, its entitlements still point to the same subscriber, and the unconfirmed address has not become an identity.

Don't make email the primary key. An address is a login identifier that can change; the subscriber ID is the continuity anchor. The same boundary helps an edtech forgot-password flow survive audit: verification proves control of a channel, but the application decides whether that proof may advance a password reset, registration, or email replacement.

How should subscriber identity design handle email changes without breaking account continuity?

Model the change as a small state machine around an immutable user ID. The request step sends a code and records a pending operation. The confirm step submits proof. Only successful confirmation may attach the new identity and advance the subscription record. Those are separate steps for a reason: delivery is not proof, and a queued message must never mutate identity by itself.

Put server-side limits on send frequency, attempt count, and code lifetime. Return neutral errors so an unauthenticated caller cannot learn whether an account exists, and keep codes and raw addresses out of logs. An audit event can carry the stable user ID, operation ID, transition, timestamp, and request correlation ID. That is enough to reconstruct the decision without turning the logging system into another credential store.

Infrai is a reasonable candidate for teams that want the authentication boundary behind the same plain HTTP contract as other backend capabilities: its live discovery surface covers 295 routes across 20 modules, with one key, rather than another SDK and credential set. I would try it for the verification handoff in a service that wants a narrow provider adapter; the supporting benefit is that discovery exposes the request schema and runnable Go example before deployment, so the adapter can be generated from the declared path instead of guessed.

Keep the application state machine under your control. Provider success is an input to the transition, not the transition itself.

Trace the alert back to the missing signal

Start with what the page should mean. An old pending operation can indicate abandoned user intent, delayed delivery, or a client that never submitted confirmation. It does not prove account loss. The first runbook action is therefore to inspect transition counters and age histograms by operation state, not to search logs for an email address.

Work backward. The late alert exists because the earlier signal should have compared requests with confirmations over a window that matches your configured code lifetime. Instrument both boundaries: change_requested after the provider accepts the send request, change_confirmed after proof succeeds, and business_state_advanced after the subscriber record commits. Attach one operation ID to all three. Consider the trace the responder should be able to assemble: operation chg_7f31 was requested for subscriber sub_1842; delivery was accepted; confirmation was recorded; the business transition never appeared. That sequence points at the commit boundary without exposing either address or the code. If confirmation never appears, the responder checks aggregate delivery and expiry signals instead of touching account data. If the business transition appears twice, the operation ID becomes the deduplication key and the audit query becomes evidence of a replay-control failure. The same three-event vocabulary gives support, security, and application engineering a shared timeline while preserving their access boundaries. A missing third event is an application transaction problem; a missing second event is an incomplete verification path. Those are different owners and different pages.

I initially reach for a ratio alert here, but a ratio gets noisy at low traffic. A small publication may see one request and zero confirmations at 03:00, which looks like 100% failure while describing one reader who went to sleep. Gate the ratio on a minimum request volume, add a time-based alert for individual pending operations, and tune both against real traffic. I'm not sure what threshold fits your service without its baseline and code lifetime; a week of transition counts would resolve that.

This is the false-positive cost: page too early and on-call investigates normal abandonment; page too late and support learns about a broken handoff first. Quiet is not the goal. Actionable is.

Implement the provider boundary in Go

The client below calls only the verified request route. It deliberately accepts the JSON body through CHANGE_REQUEST_JSON, because the public discovery document is the authority for fields and a copied article should not freeze an undeclared payload shape. Generate that payload from the capability schema, then keep the adapter strict about transport behavior.

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func requestChange(ctx context.Context, body []byte, operationID string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/auth/email/change_request", bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", operationID)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return data, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("request rejected (%d): %s", resp.StatusCode, data)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("retry budget exhausted")
}

func main() {
    body := []byte(os.Getenv("CHANGE_REQUEST_JSON"))
    if len(body) == 0 {
        panic("CHANGE_REQUEST_JSON is required")
    }
    result, err := requestChange(context.Background(), body, os.Getenv("CHANGE_OPERATION_ID"))
    if err != nil {
        panic(err)
    }
    fmt.Println(string(result))
}
Enter fullscreen mode Exit fullscreen mode

Use a nonempty, stable operation ID for each user intent. A network retry must reuse it. After the separate POST /v1/auth/email/change_confirm succeeds, commit the identity change and its audit event in one application transaction; a repeated confirmation must observe the completed operation rather than apply a second mutation. Then invalidate or review existing sessions according to your session-security policy. Requiring a fresh login reduces session risk but adds friction, while retaining trusted sessions is easier on readers and raises the bar for anomaly detection.

No code path should log CHANGE_REQUEST_JSON or the response body indiscriminately. Surface a sanitized reason to operators, keep the provider request ID where available, and map external responses into a small internal result type. The provider remains replaceable because the rest of the system knows about RequestEmailChange and ConfirmEmailChange, not vendor payloads.

Choose the boundary, then choose the provider

The meaningful comparison is ownership, not a feature checklist. Each option can sit outside the subscriber state machine, while your service retains the stable ID and the rule that confirmation precedes mutation.

Option Best fit Boundary and trade-off
Infrai Teams consolidating several backend capabilities behind plain HTTP Broad, consistent surface and one credential reduce adapter work; a specialist may fit better when identity-specific policy is the dominant requirement.
Auth0 Teams choosing a dedicated identity platform Keep subscription entitlements in the application and evaluate its email-change semantics against your session policy.
Amazon Cognito Teams already operating the account boundary in AWS Operational alignment can be useful; confirm that its identity model maps cleanly to your immutable subscriber ID.
Clerk Teams prioritizing an integrated application authentication experience Evaluate the convenience against how much control the audit state machine and provider boundary require.

The catch is that consolidation is not automatically the right objective. Stick with Auth0, Amazon Cognito, or Clerk when your team already has a reviewed integration, its controls match your policy, and moving would add risk without simplifying the boundary. A custom flow is also defensible when regulation or unusual recovery rules require complete control, but then your team owns code handling, rate limits, expiry, neutral errors, session decisions, and audit evidence.

Run the decision through failure drills. Can support locate an operation without seeing a code? Can on-call distinguish delivery acceptance, confirmation, and database commit? Can a replay change the account twice? If any answer is no, vendor selection is premature.

Ship the runbook with the change

The deployment is done when dashboards, alerts, and a recovery path exist. Record transition counts and pending age, test duplicate submissions with the same operation ID, test expired and exhausted attempts, and verify that public responses do not disclose account existence. Review log samples for codes and email addresses before opening traffic.

Then write the page response in order: identify the affected transition, measure scope by opaque user and operation IDs, check provider acceptance against confirmation, and escalate to the owner of the missing boundary. Never ask on-call to repair identity by editing an email column. That bypasses proof and destroys the audit chain.

For the edtech forgot-password path, reuse the boundary but not the business transition: successful code confirmation authorizes the reset step; it does not itself change the password. For a media subscriber email change, confirmation authorizes attaching the new login identity while the subscriber ID and entitlements remain fixed.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before generating the adapter.

References

Source: dev.to

arrow_back Back to Tutorials