Short answer: don't reset a credential until the recovery session has proved possession and the server has inspected the account's actual login methods. Before that proof, return the same generic response for every identifier. After it, send password users toward a credential reset and social-only users toward Google or GitHub reauthentication.
For a logistics platform, that distinction is operational, not cosmetic. A dispatcher locked out during a loading window needs the shortest valid route back in. A bot testing a leaked driver-email list should learn nothing. Identity-assisted recovery serves both goals when method inspection happens behind a one-time recovery boundary, never on a public “does this email use Google?” endpoint.
Blind reset before, evidence-led recovery after
The tempting design is a straight line: enter email, receive reset link, set password. It assumes every account owns a local password. Once Google and GitHub sign-in are wired into the same product, that assumption breaks. A social-only user can receive a perfectly delivered message and still be sent to a form for a credential that doesn't exist.
Use a different mental model. Before: identifier -> reset form. After: identifier -> neutral notification -> possession proof -> method inspection -> eligible recovery action.
That extra decision point must stay server-side. If an unauthenticated caller can ask which login methods belong to an email address, the recovery feature becomes an account and identity-provider enumeration tool. The public response should remain generic, and its broad timing behavior should be consistent, whether the account exists, uses a password, or uses social sign-in. OWASP's authentication guidance recommends generic responses for authentication and recovery flows because differing messages can expose account validity.
No proof, no disclosure.
This is the crisp boundary: notification is not identity proof. Clicking a valid, single-use recovery link can establish a narrow recovery session; it shouldn't silently create a normal application session or authorize unrelated account changes. Keep the scope small.
How should identity-assisted recovery inspect login methods before resetting credentials?
Start with an account model that separates identities from recovery capabilities. A person may have a local password, a Google identity, a GitHub identity, or a linked combination. Store those as server-owned records. Don't infer them from the submitted email domain, and don't let the browser declare which methods exist.
Then make the sequence explicit:
- Accept an identifier and immediately return one generic message.
- Apply abuse controls to the request using several signals, such as a normalized-identifier digest, IP range, device signal, and recent send history.
- If an eligible account exists, issue a random, expiring, single-use recovery token and send it through the account's established recovery channel.
- Exchange that token for a narrowly scoped recovery session.
- Only now load the account's login methods and calculate allowed actions.
- Complete either a password reset or a fresh social reauthentication, then invalidate the recovery session.
Picture the state machine in one line: requested, notified, proved, inspected, completed. Each arrow is an event worth measuring. There is no public branch labeled “unknown user,” and there is no pre-proof response labeled “GitHub account.”
The policy needs one more guard: linked identities aren't interchangeable evidence. Possession of the recovery channel may permit a local password change under your policy, but changing or unlinking a social identity is a higher-impact account-management operation. Treat it separately. Fast recovery is useful; accidental identity takeover is faster.
A copyable TypeScript recovery policy
The following example is deliberately vendor-neutral. It assumes token verification, identity lookup, and rate limiting are implemented behind interfaces, so the policy can be tested without a network call. The result contains only actions that may be displayed after proof.
type LoginMethod =
| { kind: "password"; enabled: true }
| { kind: "social"; provider: "google" | "github"; subject: string };
type RecoveryAction =
| { kind: "reset_password" }
| { kind: "reauthenticate"; provider: "google" | "github" };
type RecoverySession = {
accountId: string;
purpose: "account_recovery";
expiresAtMs: number;
consumedAtMs?: number;
};
interface RecoveryStore {
consumeToken(token: string): Promise<RecoverySession | null>;
listLoginMethods(accountId: string): Promise<LoginMethod[]>;
}
type InspectionResult =
| { status: "denied" }
| { status: "ready"; actions: RecoveryAction[] };
export async function inspectRecoveryMethods(
token: string,
store: RecoveryStore,
nowMs = Date.now(),
): Promise<InspectionResult> {
const session = await store.consumeToken(token);
if (
!session ||
session.purpose !== "account_recovery" ||
session.consumedAtMs !== undefined ||
session.expiresAtMs <= nowMs
) {
return { status: "denied" };
}
const methods = await store.listLoginMethods(session.accountId);
const actions: RecoveryAction[] = methods.map((method) =>
method.kind === "password"
? { kind: "reset_password" }
: { kind: "reauthenticate", provider: method.provider },
);
return { status: "ready", actions };
}
One detail deserves scrutiny: consumeToken must be atomic. Two requests presenting the same token shouldn't both advance. The interface name encodes that requirement, while the storage implementation decides how to guarantee it.
Keep public initiation just as plain. Return the same accepted response even when no notification is sent. A 429 may still be appropriate for a broadly rate-limited client, but don't vary that decision solely because an account was found. For token inspection, an expired, malformed, or already consumed token can share a 401-class outcome and the same user-facing restart path. Those are application design choices, so test the exact mapping your threat model selects.
For the logistics example, the post-proof UI might offer “Continue with Google,” “Continue with GitHub,” or “Reset password.” It should not expose provider subject identifiers, internal account IDs, or a history of unlinked methods. Less data. Fewer clues.
Walk one shipment coordinator through it. The coordinator types ops@example.test at 08:12, while an automated client submits the same address seconds later. Both initiation requests receive identical public copy. Internal policy may suppress one notification after evaluating rate and device signals, but neither caller sees that branch. The coordinator opens the valid one-time link, so the server consumes it and discovers that this account has Google and GitHub identities but no password method. The page offers two reauthentication actions and no password form. If the coordinator refreshes the old link after choosing Google, token consumption denies the replay and directs the browser to restart recovery. Meanwhile, the event trail connects request, proof, inspection, and completion through opaque correlation values. An investigator can compare the human sequence with the automated burst without searching logs for the coordinator's raw email or recovery token. That's the before-and-after in operational terms: the old flow manufactured a useless password task; the new flow reveals the smallest correct choice only after evidence arrives.
Replay fails.
What should recovery observability reveal without leaking identity data?
Logs should explain the state machine without becoming a second identity database. Record an event name, a request correlation ID, a keyed digest of the normalized identifier, coarse client-risk attributes, policy outcome, and latency bucket. Avoid raw tokens and raw email addresses. Metrics can then count initiation, notification eligibility, proof success, inspection, completion, denial, and rate limiting by coarse dimensions.
The useful comparison is between stages. A surge in recovery.requested with flat recovery.proved suggests automated probing or delivery trouble; a normal proof rate followed by a fall in recovery.completed points toward user-flow friction after verification. That interpretation is a hypothesis — not a verdict — because delivery telemetry, deployment changes, and client errors can produce similar shapes. Correlate before paging someone.
Alerting needs restraint. A single failed token is routine. A sustained rise in denied token exchanges across many identifier digests, concentrated by network or device signal, deserves investigation. Set thresholds from your own baseline; I'm not sure a universal number would survive differences between a regional freight portal and a public parcel-tracking app.
Test the workflow at three layers. Unit tests should cover password-only, social-only, linked, expired, and replayed sessions. Integration tests should prove token consumption is atomic and that pre-proof responses don't reveal account state. In a staging exercise, replay a burst against known and unknown identifiers, then verify that the logs support investigation without containing the submitted addresses.
The catch is added state and operational work. This pattern is not suitable when the service has exactly one immutable login method and recovery can't branch; a conventional, carefully protected reset flow is easier to reason about there. It is also a poor substitute for support-led recovery of high-impact administrator accounts, where documented human verification and dual control may be justified. Stick with social reauthentication when an account is social-only. Use a password reset only when a password method actually exists.
Two objections worth answering
“Why not show the login method immediately? It saves a click.” It also turns an email address into a query for account existence and provider affiliation. Put convenience after possession proof. The extra boundary is doing security work.
“Why not always add a password during recovery?” Because recovery shouldn't mutate the authentication model as a side effect. Adding a new login method changes how the account can be entered later. Make that a separate, authenticated account-management decision with its own audit event and confirmation policy.
The deployment rule is simple: compare completion and abuse signals before and after the change, but don't call a higher completion rate a win if enumeration resistance or replay protection regressed. Recovery is part of authentication. It shouldn't be weaker than the path it repairs.
Top comments (0)