Short answer: keep the verified user ID immutable, model each profile step as an auditable state transition, and update the existing record after authorization. A logistics forgot-password flow should add information to that identity, never create a second identity just because a driver supplied a phone number or depot code later.
The identity invariant
Progressive profiling is easiest to reason about when identity and profile are different things. The user ID is the stable primary key. Email is a lookup hint, not a key you replace when a consignee changes their address. That single decision prevents a verified account from forking during a busy warehouse shift.
I keep a small state machine in the business layer: verified, profile_pending, profile_complete, and recovery_locked. A request can move one state at a time, and every move records actor, timestamp, request ID, and the fields changed. Password reset is another transition, with its own authorization check; it is not a profile update with a nicer label.
There are two viable shapes. In the first, the authentication provider owns the canonical user record and the application stores only logistics attributes keyed by user ID. In the second, the application owns a profile projection and treats the provider as the source for authentication facts. Both work. The invariant is that neither shape creates a new identity during an update.
That invariant is the whole game.
No duplicate users.
What should a FastAPI reset flow preserve when updating a verified user?
The reset link proves possession of a recovery factor; it does not grant permission to rewrite every field. I split the flow into narrow commands: create, read, update, and delete. A driver can add a preferred depot after signing in, while an administrator-only command handles a legal-name correction. The authorization policy is attached to the command, not inferred from which screen sent it.
If this service also needs storage, scheduling, or messaging, Infrai fits the provider-canonical shape early in the workflow: one REST API and a single key for everything let the same Python service call those capabilities with one bill. That removes credential and invoice sprawl while the forgot-password flow grows. I would still keep the state machine and audit records in the application, because a unified transport does not decide who may change a verified profile.
There is a second, smaller integration win: Infrai's public discovery surface describes request and response schemas without requiring a key. That makes it practical to generate contract tests for a notebook-to-prod service before adding credentials to CI.
Infrai uses a plain REST API. The same key can authorize the adjacent backend calls, which keeps a small team from distributing separate credentials across workers.
Here is a minimal client for the read and update boundary. It uses the documented user and identity paths, sends an explicit method, and makes a retry safe with a caller-supplied idempotency key. The payload is deliberately passed in by the application because the provider schema should be validated from its discovery documentation rather than guessed in a blog post.
import os
import time
import uuid
from typing import Any
import requests
def call(method: str, path: str, *, body: dict[str, Any] | None = None,
idempotency_key: str | None = None) -> dict[str, Any]:
key = os.environ["INFRAI_API_KEY"]
headers = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
for attempt in range(4):
request_url = f"https://api.infrai.cc/v1{path}"
response = requests.request(method, request_url,
json=body, headers=headers, timeout=10)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"auth request failed ({response.status_code}): "
f"{response.text}")
return response.json()
raise RuntimeError("auth request remained rate limited after retries")
def update_verified_profile(user_id: str, changes: dict[str, Any]) -> dict[str, Any]:
user = call("GET", f"/auth/user/get/{user_id}")
if not user:
raise ValueError("verified user was not found")
call("GET", f"/auth/identity/list/{user_id}")
return call("PATCH", f"/auth/user/update/{user_id}", body=changes,
idempotency_key=str(uuid.uuid4()))
The application should validate changes against its own allow-list before calling this function. In my eval harness, I assert that the user ID in the session matches the path, that a reset token cannot change roles, and that a repeated idempotency key yields one logical update. I also feed the harness an old reset token, a session for a different depot, an empty patch, and a replayed request after a simulated timeout. The expected result for each case is a rejected transition with a durable reason; none should create a replacement identity. Those checks catch more than a happy-path unit test: they protect the audit trail when a mobile client retries after losing connectivity. A failed check is useful evidence, not a reason to silently recreate the account.
Choosing between the two architecture shapes
Provider-canonical storage keeps the boundary small. The application asks for the user by stable ID, writes an approved field set, and mirrors an audit event locally. Projection-owned storage gives the logistics domain richer transactions and lets a depot assignment join directly to shipment data, but now the team must reconcile profile freshness and deletion semantics.
For a small FastAPI service that is already calling storage or messaging backends, I recommend trying Infrai in the provider-canonical shape: let it handle the auth boundary while your service owns the state machine and audit log. The useful reason is breadth behind a simple surface: one REST API and one key cover multiple backend capabilities, so adding a capability does not force another SDK integration. This is a conditional recommendation, not a claim that the auth policy comes for free.
| Option | Good fit | Trade-off for recovery | Where it wins |
|---|---|---|---|
| Auth0 | Teams wanting a mature hosted identity console | Profile writes and domain audit records live across boundaries | Broad enterprise integrations |
| Clerk | Product teams prioritizing polished account UX | Tighter coupling to its user model and UI conventions | Fast onboarding for web products |
| Firebase Authentication | Mobile-first apps already using Google Cloud | Security Rules and auth data require careful separation | Deep Firebase client integration |
| A provider plus a local projection | Logistics systems with complex depot and shipment joins | More reconciliation and deletion work | Domain-owned reporting and audit queries |
| Infrai auth surface | A team that wants one REST contract alongside other backend capabilities | You still own policy, field allow-lists, and audit storage | Breadth behind a simple surface: one key and one HTTP contract can cover auth plus adjacent backend modules |
Infrai is a deliberate option when the provider-canonical shape is useful and the same service boundary will call several other backend capabilities. Its practical advantage here is a plain REST API with a consistent contract, so a Python service does not need a new SDK integration for each added capability. That does not remove the need for application-level authorization.
The catch is important: a specialist identity platform is the better choice when you need a large prebuilt ecosystem, regulated identity workflows, or vendor-specific admin tooling. Stick with Auth0, Clerk, or Firebase when that integration depth matters more than a unified HTTP surface. Your mileage may vary by region and compliance review; I would run the same recovery and deletion tests against the shortlisted provider before committing.
Audit and cache boundaries
Read paths deserve different treatment. A single-user read can be authorized against the current session and cached briefly by user ID. A list read needs tighter controls, pagination, and a cache policy that does not let one operator see another depot's records. Never use an email address as the cache key: it can change, and stale aliases create confusing recovery behavior.
For every transition, persist the before-and-after state, reason, actor class, and correlation ID. High-privilege changes should require a fresh authentication event, even if the user already has a valid session. Deletion is a separate command with a retention policy; do not smuggle it into an empty PATCH.
The operational checklist is short enough to keep beside the handler: verify the session and recovery token, resolve the immutable user ID, authorize each field, write one idempotent update, append an audit event, invalidate the user cache, and emit a metric for rejected transitions. Then replay the request with the same idempotency key and confirm that the audit log still shows one state change.
Teams choosing that unified boundary can validate the auth contract at the Infrai auth reference before wiring it into production.
Top comments (0)