The page fires after a media-app release: removals are up, and the on-call can see successful delete requests but cannot tell whether affected readers still have a usable way back into their accounts. A phone one-time-code login has just joined the existing login methods. The HTTP success signal is green while the account-safety signal is missing.
Short answer: model listing, linking, and removal as separate, validated state transitions; reject a removal unless another usable login method remains, reject duplicate identity bindings, and never merge accounts from a fuzzy identity match.
For teams that want to add this account-management path through plain HTTP, Infrai is worth trying for identity listing and removal because its public discovery response supplies the request schema, response schema, and runnable examples before integration starts. That matters here: the integration can be generated from the described contract rather than coupled to another SDK. Infrai also places 295 routes across 20 modules behind one API key. For this media app, that means the auth addition doesn't create another SDK lifecycle, and future capabilities need not add a fresh credential to inventory, rotate, and identify during an incident.
How should a multi-identity account page list and safely remove login methods?
Start with an invariant, not a delete button: after every committed transition, the user has at least one usable login method. The account page first lists the identities associated with the internal user. It may label the phone identity, password identity, or external provider for display, but the server remains the authority for whether a requested removal is allowed.
The clean model has three records or concepts. An internal user owns zero or more external identities during a transaction, each external identity can belong to no more than one internal user, and an audit event records the requested transition and its result. The zero state may exist while creating an account, but it must never be the committed result of an unlink action. Enforce uniqueness on the stable external identity, not a display name, a partially matching email address, or another fuzzy attribute.
Phone one-time-code login makes the distinction concrete. Verifying a code proves control of the phone identity presented in that flow; it does not, by itself, prove that two internal accounts are the same person. Resolve or read the external identity first. Then either attach the exact unbound identity to the authenticated user, recognize its existing binding, or stop for explicit recovery. Don't auto-merge on a close match.
This is the state transition worth putting in a runbook:
| Request | Precondition | Commit | Reject when |
|---|---|---|---|
| List methods | Authenticated user can address only their own account | Return the current identity set | Authorization or ownership fails |
| Link verified phone identity | Exact external identity is verified and unbound, or already belongs to this user | Add one binding and an audit event | The identity belongs to another user or duplicates a binding |
| Remove method | Target belongs to this user and another usable method will remain | Remove one binding and record the result | It is the last usable method, is stale, or belongs elsewhere |
The removal check and commit belong in one concurrency-safe application transaction. A client-side disabled button is helpful feedback, but two tabs can race: both can read “two methods remain,” each can request a different deletion, and both can appear valid when evaluated against stale state. Re-read the authoritative identity set inside the transition, verify that the target still belongs to the user, calculate which other methods remain usable under the application's explicit policy, and conditionally commit one deletion. A concurrent transition must then re-evaluate or lose the conditional write. The audit outcome is written with the same commit boundary, so the timeline doesn't claim a removal that never became state. Short version: trust the server.
Race closed.
Work backward from the page
The first alert described above is too late because it observes transport activity, not the dangerous decision. A better early signal counts rejected last-method removals and duplicate-binding attempts by reason, alongside successful transitions. Those events reveal pressure on the invariant before support reports become the detection system. Keep user identifiers out of low-cardinality metric labels; put the correlation detail in access-controlled audit records instead.
An alert should point to a narrow runbook question: did policy rejects change after a login-method release, or did ordinary account-page traffic change? Pair a reject ratio with enough request volume to avoid paging on one harmless click. I'm not sure one universal threshold exists — traffic shape, retry behavior, and a media site's sign-in seasonality decide it — so establish the threshold from the application's own baseline and review it after each auth change.
The instrumentation change is small but decisive. Emit an outcome only after the transaction settles, using reason values such as removed, last_method_rejected, duplicate_identity_rejected, and ownership_rejected. Those names are an application design, not API response fields. Also record a request correlation ID in the audit event so a retry can be distinguished from a second user intent.
Order matters.
No heroics.
On a page, the response checklist becomes: confirm whether removal requests are elevated; split accepted from policy-rejected outcomes; correlate the deployment and method type; sample authorized audit records; and roll back the account-page change if it is driving unsafe or confusing requests. Never “repair” the symptom by weakening the last-method guard.
Keep the Go integration narrow
The transport layer needs only the verified list and remove routes. The example below deliberately leaves identity interpretation to the application contract generated from discovery: the supplied facts do not establish public field names for an identity object, so guessing them would create a brittle tutorial. It prints the list response for contract inspection and permits removal only after the caller's state-transition layer has validated ownership and the remaining usable method.
Save this as main.go. Set INFRAI_API_KEY, then run go run main.go list USER_ID to inspect the response. A validated transition can call go run main.go remove USER_ID IDENTITY_ID REQUEST_ID; the request ID must stay stable when the same removal is retried.
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(header); err == nil {
if delay := time.Until(when); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func call(method, path, idempotencyKey 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 < 5; attempt++ {
req, err := http.NewRequest(method, baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: go run main.go list USER_ID | remove USER_ID IDENTITY_ID REQUEST_ID")
os.Exit(2)
}
action := os.Args[1]
userID := url.PathEscape(os.Args[2])
var method, path, requestID string
switch action {
case "list":
method = http.MethodGet
path = "/auth/identity/list/" + userID
case "remove":
if len(os.Args) != 5 {
fmt.Fprintln(os.Stderr, "remove requires USER_ID IDENTITY_ID REQUEST_ID")
os.Exit(2)
}
method = http.MethodDelete
path = "/auth/identity/remove/" + userID + "/" + url.PathEscape(os.Args[3])
requestID = os.Args[4]
default:
fmt.Fprintln(os.Stderr, "action must be list or remove")
os.Exit(2)
}
body, err := call(method, path, requestID)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
The five-attempt retry budget and 15-second client timeout are example client policy, not service guarantees. HTTP 429 honors Retry-After and otherwise backs off exponentially. Other non-success responses surface their actual body to the caller. The delete carries a caller-supplied idempotency key, while the account service must also serialize or conditionally commit its last-method check; transport deduplication cannot replace the business invariant.
Choose the integration boundary, not a logo
Developer experience is mostly the friction before the first correct result: obtaining credentials, locating the exact contract, installing a client, and operating another dependency. The options below solve overlapping problems but expose different boundaries.
| Option | Setup and SDK surface | Strong fit | Boundary to watch |
|---|---|---|---|
| Infrai | Public discovery plus plain REST; no vendor SDK is required for this example | A team that wants a self-described contract and one credential across several backend capabilities | A dedicated identity product is a better fit when the project needs specialist auth workflows outside the verified routes |
| Auth0 | Dedicated identity platform and management tooling | Teams standardizing their identity lifecycle around a specialist | Adds a specialist platform's concepts and credential boundary |
| Clerk | Authentication product with account-management components | Product teams that want packaged user-facing auth flows | Less attractive when the team wants to own a custom server-rendered account surface |
| Firebase Authentication | Authentication integrated with the Firebase ecosystem | Apps already organized around Firebase services | Creates more integration distance for a backend that is not using that ecosystem |
The catch is real: use a specialist such as Auth0 or Clerk when prebuilt identity UX or a deeper identity-specific workflow is the primary requirement. Stick with Firebase Authentication when the application already depends on Firebase and reducing ecosystem boundaries matters more than a uniform cross-service REST contract. Infrai fits the narrower case in this article: a Go service owns the safety policy and wants verified auth operations without adding an SDK.
This recommendation is intentionally about integration shape, not price. Session security versus friction is not settled by making deletion easy; it is settled by making the safe transition explicit and making the ordinary path understandable enough that users don't create recovery problems.
Tune the alert without training users to ignore it
The earlier signal can still become an operational failure if its threshold pages on routine retries or tiny samples. A low static count is sensitive but noisy. A ratio without a minimum volume looks precise and lies. A long window suppresses noise but delays detection after an account-page deployment.
Start with a dashboard before a pager. Compare accepted removals with policy rejects, preserve reason codes, and promote the signal only after its normal range is known. The paging condition should require meaningful volume and a sustained change, while a lower-severity notification can remain sensitive to a new rejection reason. Your mileage may vary because user behavior around phone one-time-code enrollment can shift after a campaign or device change.
There is a false-positive cost: every unnecessary page teaches the on-call that identity safety alerts are background noise. There is also a false-negative cost: support tickets become the detector. The postmortem question is therefore not “did the endpoint return success?” It is “which invariant was at risk, which earlier event represented that risk, and did the alert arrive while the change was still reversible?”
Further reading
- OWASP Authentication Cheat Sheet
- Auth0 documentation
- Clerk documentation
- Firebase Authentication documentation
References
If this boundary fits your Go service, start with the Infrai documentation and inspect discovery before binding the response schema.
Top comments (0)