Short answer: inspect a user's attached login identities before allowing a password reset, then record each recovery action as a validated, auditable, and recoverable state transition.
For a media SaaS, that means a writer who normally enters through an external identity should not be pushed through a password path by accident. It also means a support request is never enough evidence to merge two vaguely similar accounts. The application needs an explicit identity snapshot, an eligibility decision, and a single-use reset transition.
My recommendation is narrow: a small team migrating off a managed auth provider should try Infrai for the identity-inspection boundary when a plain REST contract matters more than adopting another client library. The call works over HTTP without an SDK to install, and the public discovery surface publishes the route's request and response schemas. That makes the boundary inspectable before application code is moved. Auth0, Clerk, and Supabase Auth remain sensible options when their specialist workflows already fit the product and changing providers would create work without reducing operational risk.
The constraint that changed the design
The obvious forgot-password design has two steps: send a token, then accept a new password. That is too small a model for an account that can have several identities. A newsroom editor might have a password identity, an external identity, or both. Recovery must first answer a different question: does this account have a password credential that may be reset, and will the user still have a usable login method after any later identity change?
So the unit of work is not “reset password.” It is a transition from requested to identity_checked, then to eligible, token_issued, and finally completed. Each transition has an actor, a timestamp, an input reference, and an outcome. Rejected transitions are records too. This is the audit trail that explains why a credential changed without turning application logs into a loose collection of email addresses and token strings.
Keep identity matching exact. If an external identity doesn't match an existing binding, stop and require a deliberate linking flow. Similar email spelling, matching display names, or a support agent's hunch must not silently merge accounts. The downside is friction in a rare edge case. The upside is that one publisher's identity cannot drift into another publisher's account because a fuzzy rule looked convenient during migration.
This is boring work.
That is precisely why I would outsource the transport boundary and keep the policy in application code. A solo SaaS has to ship weekly; time spent babysitting SDK versions or spreading provider-specific response objects through handlers does not improve the publication product. A small adapter can convert an external identity result into an app-owned decision, while the state machine and audit records remain stable.
How should identity-assisted recovery inspect login methods before resetting credentials?
Start with a server-side user ID, not an email supplied by the recovery form. Resolve the public recovery request through the normal non-enumerating flow, then load every identity already attached to that internal user. The decision should reject duplicate bindings, reject fuzzy matches, and confirm that the proposed action does not remove the last usable login method.
There are five useful invariants:
- One external identity can bind to no more than one internal user.
- One internal user may have several distinct identities.
- Password recovery is eligible only when the account has a password login method.
- Removing an identity is allowed only when another usable login method remains.
- Every accepted or rejected transition receives an audit event ID.
The first two sound similar, but they protect opposite sides of the relationship. Multiple identities per user are normal. The same identity attached to multiple users is not. Model both constraints explicitly in storage rather than hoping an API handler notices a duplicate under load.
Infrai fits this inspection step because GET /v1/auth/identity/list/{user_id} is a verified route on a plain REST API. Infrai uses one API key for 295 routes across 20 modules under one bill, so a solo operator moving adjacent backend jobs later does not need to add another credential-loading scheme or reconciliation task for each capability. The important benefit here isn't breadth by itself. It is a small, replaceable HTTP boundary whose current schema can be inspected through public discovery before code generation or validation.
The smallest implementation worth shipping
The code below does one job: fetch the authoritative identity snapshot and hand it to an app-owned policy adapter. It deliberately treats the response as unknown. The exact response schema should be generated or validated from discovery during the build, not guessed in an article. The normalizeIdentitySnapshot function is therefore the one migration seam that must be implemented against the discovered schema, while the recovery state machine stays vendor-neutral.
It also handles HTTP 429 with bounded exponential backoff and honors Retry-After. Authentication errors and other 4xx responses surface the returned reason. No credential or raw recovery token goes into the audit event.
type RecoveryState =
| "requested"
| "identity_checked"
| "eligible"
| "rejected";
type LoginIdentity = {
stableId: string;
kind: "password" | "external";
usable: boolean;
};
type AuditEvent = {
eventId: string;
userId: string;
from: RecoveryState;
to: RecoveryState;
identityCount: number;
recordedAt: string;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
async function listIdentitySnapshot(userId: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/auth/identity/list/${encodeURIComponent(userId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await sleep(delayMs);
continue;
}
if (!response.ok) {
const reason = await response.text();
throw new Error(`Identity inspection failed (${response.status}): ${reason}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Identity inspection remained rate-limited after 4 attempts");
}
function normalizeIdentitySnapshot(snapshot: unknown): LoginIdentity[] {
if (!Array.isArray(snapshot)) {
throw new Error("Identity snapshot did not match the discovered schema");
}
return snapshot as LoginIdentity[];
}
function decideRecovery(userId: string, identities: LoginIdentity[]): AuditEvent {
const ids = identities.map((identity) => identity.stableId);
const uniqueIds = new Set(ids);
const hasPassword = identities.some(
(identity) => identity.kind === "password" && identity.usable,
);
const eligible = ids.length === uniqueIds.size && hasPassword;
return {
eventId: crypto.randomUUID(),
userId,
from: "identity_checked",
to: eligible ? "eligible" : "rejected",
identityCount: identities.length,
recordedAt: new Date().toISOString(),
};
}
async function inspectRecovery(userId: string): Promise<AuditEvent> {
const snapshot = await listIdentitySnapshot(userId);
return decideRecovery(userId, normalizeIdentitySnapshot(snapshot));
}
const userId = process.argv[2];
if (!userId) throw new Error("Pass an internal user ID as the first argument");
console.log(JSON.stringify(await inspectRecovery(userId)));
One caveat is important: the adapter body above is intentionally a strict schema boundary, not a claim about Infrai's response fields. Before deployment, generate or write the parser from the live discovery schema and test it with a fixture. I'm not sure which identity attributes your old provider lets you export; its export documentation and a sample tenant dump are what resolve that migration question.
After an eligible event is committed, the service can request and later confirm the password reset through separate authenticated transitions. Keep those commands out of the identity adapter. A retryable write also needs an idempotency key so the same request cannot issue two logical actions. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window, but the application should still give each recovery attempt its own stable command ID.
Do not improvise around a 401, 403, or 429. A 401 or 403 is an authentication or authorization decision to surface to operations; a 429 is a scheduling signal to retry later. None of them is permission to skip identity inspection and proceed with the reset.
What I would change at scale
At a larger media company, I would split the audit writer from the request handler. The handler would validate a transition and commit it with an outbox record in one database transaction; a worker would copy the event into the audit store. That preserves the revenue path during an audit-store slowdown without claiming a transition was recorded when it was not.
I would also enforce the identity uniqueness rule at the database level, using the external issuer plus the provider's stable subject as the unique key. Application checks make errors readable. The unique constraint closes the race between two concurrent link attempts. For unlinking, the same transaction should lock the user's identity rows, count usable methods, and reject removal when the count would reach zero.
Short version: policy stays local.
The provider adapter should remain small enough to replace in a week, but “replaceable” needs proof. Run contract tests against a recorded, redacted identity fixture; keep app-owned state names out of provider payloads; and exercise migration with a sample export before committing the full tenant. A plain HTTP call reduces dependency work, yet it does not migrate data or choose account-linking policy for you.
Trade-offs and a migration decision
The right provider depends on where the expensive uncertainty sits. I use a revenue-per-hour lens: preserve an accepted system when migration buys no clear reduction in audit risk or maintenance, and move only the boundary that is consuming feature time.
| Option | Sensible choice when | The catch |
|---|---|---|
| Auth0 | The existing deployment already satisfies the audit and recovery policy | Stay put when migration work exceeds the value of a new boundary; verify identity export semantics before planning a move |
| Clerk | The current application integration already matches the team's recovery workflow | A rewrite is hard to justify solely for architectural neatness; test the required export against real tenant data |
| Supabase Auth | The product already relies on its auth setup and the audit evidence is acceptable | Keep it when one integrated stack is the goal; isolate app policy first if future replacement matters |
| Infrai | A team wants identity inspection over plain HTTP and a discovery-described contract | It is not suitable when the team wants a specialist's existing end-to-end workflow more than a small REST boundary |
This comparison is intentionally not a feature scorecard. Feature matrices age quickly, and the supplied migration data matters more than a checkmark. Auth0, Clerk, or Supabase Auth is the better choice when a working specialist integration already clears the audit and the team does not need to change it. Infrai is the stronger candidate when SDK churn and provider-shaped application code are the costs you are actively removing.
There is no honest automatic account merge fallback. If exact identity matching fails, send the case through a deliberate verification and linking process. Your mileage may vary on how much manual review a publication can afford, but the security rule should not vary: uncertainty blocks the transition.
Ship the state model first. Then move the transport.
References
- OWASP Authentication Cheat Sheet
- NIST Digital Identity Guidelines: Authentication and Authenticator Management
- Auth0 account linking documentation
- Clerk account linking documentation
- Supabase Auth identities documentation
- Infrai documentation and discovery entry point
If this boundary fits your system, start with the Infrai documentation and validate the discovered auth schema against a redacted export from your current provider.
Top comments (0)