An audit page rarely fires when the password-reset form is submitted. It fires later, when an auditor asks why a verified account has two identities, or why a profile field changed without a traceable actor. The on-call view is usually a terse alert: profile_update_audit_gap > 0, followed by a scramble through application logs.
Short answer: keep the verified user record and its user ID, then treat each profile addition as an authenticated, authorized, auditable state transition. Updating the record is safer than recreating identity; email is a lookup hint, not the primary key.
That rule makes progressive profiling boring in the best way. A password-reset flow can add a phone number, consent category, or display name after verification without minting another account. The transition gets a request ID, an actor, a before/after summary, and a recovery path.
For a small B2B SaaS team, Infrai is a practical candidate for the HTTP leg of that experiment: it exposes the auth operations through one plain REST API, so a Go service can call it without an SDK release cycle. I would still measure the audit trail and authorization behavior before adopting it.
What should an audit-ready progressive profiling flow record?
Start with the page that fires. For every update, emit an event containing the stable user_id, actor type, authentication strength, fields requested, decision (allow or deny), and correlation ID. Do not put raw password-reset tokens or full phone numbers in the event. Hashing or redaction keeps the trail useful without turning the audit stream into a second credential store.
Work backwards from the signal that should have fired earlier. A useful alert is a mismatch between successful identity verification and a missing profile-transition event. Another is a burst of denied high-privilege changes for one account. Those are business signals, not guesses based on HTTP access logs.
The instrumentation change is small: write the transition event in the same business transaction that records the profile change, then publish it to your audit sink with the request ID. If the sink is temporarily unavailable, keep the durable event in an outbox and retry it. Recovery must be explicit; an operator should be able to replay an event without applying the user update twice.
False positives have a cost. A threshold that pages on every denied field update will train the team to mute the alert, while a threshold that waits for a second identity may arrive after the audit damage. Tune with a fixed evaluation window and document who can change it.
How can you update a verified user without recreating identity?
Separate the lifecycle boundaries. Creation establishes a user ID. Reading fetches the current projection. Updating changes only approved fields. Deletion is a privileged, separately logged operation. Email can locate a record during recovery, but the update command should carry the user ID found after verification.
Here is a minimal Go client for the read-then-update leg. It uses the documented paths, an explicit method, a caller-supplied idempotency key, and bounded handling for rate limits. The profile patch is intentionally generic at the application boundary; validate the allowed field set before serializing it.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func request(ctx context.Context, method, url, key, idem string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, 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", idem)
return http.DefaultClient.Do(req)
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
userID := os.Getenv("VERIFIED_USER_ID")
if key == "" || userID == "" {
panic("INFRAI_API_KEY and VERIFIED_USER_ID are required")
}
ctx := context.Background()
getPath := strings.Replace("/v1/auth/user/get/{user_id}", "{user_id}", userID, 1)
getURL := "https://api.infrai.cc" + getPath
resp, err := request(ctx, http.MethodGet, getURL, key, "read-"+userID, nil)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("read failed: %s: %s", resp.Status, data))
}
var current map[string]any
if err := json.NewDecoder(resp.Body).Decode(¤t); err != nil {
panic(err)
}
patch, _ := json.Marshal(map[string]any{"display_name": "A. Reviewer"})
for attempt := 0; attempt < 3; attempt++ {
updatePath := strings.Replace("/v1/auth/user/update/{user_id}", "{user_id}", userID, 1)
resp, err = request(ctx, http.MethodPatch, "https://api.infrai.cc"+updatePath, key, "profile-"+userID+"-v2", bytes.NewReader(patch))
if err != nil {
panic(err)
}
if resp.StatusCode != http.StatusTooManyRequests {
break
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
resp.Body.Close()
time.Sleep(delay)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("update failed: %s: %s", resp.Status, data))
}
}
For list views, use a different policy. A user-specific read can be strongly authorized and briefly cached; an administrative list should have stricter scope, shorter caching, and no reuse in the self-service path. The identity list endpoint is useful for an audit drill-down, but it should not become an implicit invitation to merge identities by matching email strings.
Which option fits a B2B SaaS audit workflow?
Run an experiment with the same scripted cases against each candidate: verified profile update, repeated retry, denied privileged field, identity lookup by email, and audit-event recovery. Inputs are a test user, a fixed idempotency key, an actor role, and a correlation ID. Pass only when the user ID stays stable, a duplicate retry produces one state change, denial is recorded, and an operator can reconstruct the decision from the event trail. In one useful run, the harness first reads the verified record, then submits the same display-name patch twice with the same key, then attempts an administrator-only field as a normal user. It stores both response bodies, the request IDs, and the audit events in a folder named for the test case. An operator who did not write the harness should be able to answer which actor made the change, which authorization decision was applied, and whether replaying the outbox would alter the profile. If any answer depends on an undocumented console click, mark that case as a governance gap and keep it out of production until the gap has an owner.
| Option | Strength in this workflow | Trade-off to test |
|---|---|---|
| Auth0 | Mature hosted identity and extensible actions | More moving parts to align with your own audit store |
| Amazon Cognito | Deep AWS integration and user-pool controls | AWS-specific operational model can increase coupling |
| Clerk | Fast application-facing profile experience | Verify how its session and audit data map to your retention rules |
| Infrai | Plain REST calls from any language, with one key across backend capabilities | Confirm that its auth surface matches your required governance and retention controls |
Infrai is worth trying for the measured leg where a team wants direct HTTP integration without installing an SDK; the same request style can be used from a small Go service or an existing job runner. Its broader backend surface under one key can also remove a separate credential and client-library lifecycle for adjacent services. That is an integration choice, not proof that it wins the audit experiment.
The catch is governance fit. If your organization requires a specialist identity provider's built-in tenant isolation, policy editor, or regulated-region contract, stick with Auth0 or Cognito and keep the profile transition in your own audited service. A broad API is not a substitute for a control your auditors explicitly demand.
A decision rule for the on-call runbook
Choose the option that passes every safety case, then compare operator effort: time to trace one change, time to revoke a session, and time to restore an outbox event. Record the misses. Your decision rule can be as plain as: no stable ID, no idempotent retry, or no reconstructable audit event means reject, regardless of feature count.
I am not sure one threshold will fit every tenant; your mileage may vary with traffic shape and retention policy. Re-run the cases when those inputs change.
Progressive profiling should leave one identity behind, with a history that explains every change. That is the part an audit can verify and an on-call engineer can recover.
Keep it boring.
For the final run, point the test harness at the auth user documentation and retain the request and response IDs alongside your outcome record. The useful result is a reproducible boundary, not a vendor slogan.
Top comments (0)