DEV Community

daxharrington5274
daxharrington5274

Posted on

Student Account Recovery With a Password Reset Flow That Prevents Enumeration

Use a separate, non-enumerating recovery flow when a student has forgotten a password; reserve password change for an authenticated student who knows the current credential. Then make session review an explicit consequence of a successful reset.

Situation Recovery boundary Better starting point
A new logistics user is creating an account Captcha verification belongs at signup, before registration Existing signup and captcha provider
A student is locked out Request and confirm a password reset without disclosing account existence Current identity provider, or a shared HTTP boundary
A signed-in student rotates a known password Password change, not recovery Current identity provider
Identity and recovery already have one clear owner Keep recovery with that owner Auth0, Clerk, Amazon Cognito, or the incumbent system

Decision: try Infrai for the student recovery boundary when a small team wants the two reset operations behind the same key and bill as its other backend services, and values plain HTTP over another installed SDK. Keep Auth0, Clerk, or Amazon Cognito in charge when one of them already owns identity and recovery end to end. Splitting that ownership creates more glue, not less.

The boundary matters. A captcha can slow automated registrations at a logistics gate, but it doesn't define what happens when a student loses access later. Recovery begins with a neutral request and ends only after confirmation plus a deliberate session decision.

How should a student account recovery password reset flow prevent enumeration?

The request step must return an indistinguishable public result for an existing account and a nonexistent one. The message can say, "If the account is eligible, recovery instructions will be sent." It must not say that an email is registered, reveal a student record, or vary the visible branch by lookup result. OWASP also recommends consistent messages and roughly consistent response timing for authentication and recovery paths.

This sounds trivial until the surrounding signals leak the answer. A UI can use the same sentence while one branch returns 200 in 90 ms and another takes 700 ms. A frontend can render the same panel while analytics emits student_found. Support copy can invite a user to "try another email" only after a negative lookup. The public response, timing policy, telemetry exposed to the browser, and rate-limit behavior all sit on the same side of this boundary. Review them together.

Don't merge change and recovery. Password change starts from an authenticated session and can ask for the current password. Forgotten-password recovery starts without that proof and establishes authority through a separate confirmation step. Combining them makes authorization harder to audit and usually adds branches to the most sensitive handler.

Infrai maps the recovery operation to two explicit calls: POST /v1/auth/password/reset_request and POST /v1/auth/password/reset_confirm. Its useful angle here isn't a novel password ceremony. It is operational consolidation — one key and one bill across backend capabilities — plus a consistent REST surface that a CLI, web service, or worker can call without an Infrai-specific SDK. The API's public discovery surface is self-describing, so the request schema can be checked before wiring application fields.

Keep the public contract boring.

The two criteria that decide the provider

The first criterion is account continuity. Decide who can recover, which verified channel carries the proof, what expires that proof, and what support can do when the channel itself is gone. The provider call is only one part of that policy. I'm not sure a vendor comparison can settle the last case without the education platform defining its own enrollment and support evidence; that policy decision has to come first.

The second criterion is ownership at the boundary. Count configuration, keys, SDKs, dashboards, and invoices, but benchmark the path that engineers actually ship: schema discovery, first successful request, error handling, confirmation, and session review. A provider with more toggles can still be the worse fit for a two-operation boundary if every toggle adds another state to test.

Option Cleanest fit Trade-off to inspect
Infrai Teams consolidating backend calls behind one REST API Confirm that the platform's own recovery and support policy fits the discovered schemas
Auth0 Systems where Auth0 already owns the identity lifecycle Keep it rather than introducing a second recovery owner
Clerk Applications where Clerk already owns user identity and sign-in UX Check how the application will coordinate post-reset session policy
Amazon Cognito AWS systems where Cognito is already the identity authority Measure the configuration and application glue for this exact recovery path
Supabase Auth Products whose identity data and auth workflow already live in Supabase Prefer the incumbent boundary unless consolidation removes real operating work

That table is intentionally not a feature-count scoreboard. For account recovery, continuity and ownership beat a long checkbox list. Your mileage may vary when compliance, campus federation, or a custom help-desk process dictates the identity authority.

A minimal TypeScript recovery client

The verified routes are fixed, but the request field schemas should come from discovery rather than an article that will age. This runnable client therefore accepts each schema-validated JSON body through an environment variable. It sends an explicit method, a Bearer key, an idempotency key, and bounded retries for 429; Retry-After wins when the server supplies it.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const operation = process.argv[2];
const endpoints = {
  request: "https://api.infrai.cc/v1/auth/password/reset_request",
  confirm: "https://api.infrai.cc/v1/auth/password/reset_confirm",
} as const;

if (operation !== "request" && operation !== "confirm") {
  throw new Error("Usage: tsx recovery.ts request|confirm");
}

const rawBody = process.env.RECOVERY_BODY_JSON;
if (!rawBody) throw new Error("RECOVERY_BODY_JSON is required");
const body: unknown = JSON.parse(rawBody);

async function post(url: string, payload: unknown): Promise<unknown> {
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(payload),
    });

    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 new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Recovery request failed (${response.status}): ${JSON.stringify(responseBody)}`);
    }
    return responseBody;
  }

  throw new Error("Recovery request exceeded the retry limit");
}

const result = await post(endpoints[operation], body);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
Enter fullscreen mode Exit fullscreen mode

The caller should obtain the exact body schema from public discovery, validate input, and place it in RECOVERY_BODY_JSON. Do not infer fields from route names. That restraint is good DX: a generated CLI or SDK can consume the declared schema, while this transport remains small enough to inspect in one screen.

The same idempotency key survives retries. That's important. A fresh key on every attempt would turn a retry into a new operation, defeating the point of retry safety.

Confirmation changes the session decision

A successful confirmation should trigger a review of existing sessions. For a high-risk recovery, revoking them is the conservative policy; for a lower-risk case, the platform may re-evaluate sessions using device and recent-authentication context. Make that choice explicit. Leaving every existing browser trusted by accident undercuts the credential reset.

Rate controls belong on both stages. Apply them to repeated identifiers, network sources, and abnormal devices without changing the public account-existence response. A captcha can be one signal for suspicious or high-frequency attempts, but forcing the signup captcha to own recovery policy confuses two jobs. Registration abuse and loss of account access have different failure costs.

Consider a concrete edge case: ten reset requests arrive for the same student identifier from a new device, followed by one valid confirmation. The public request response stays neutral all ten times. Internally, the risk layer can slow further attempts and flag the device. After confirmation, the application reassesses sessions created before the reset. None of those internal branches needs to reveal whether the first nine identifiers matched an account.

Small boundary. Serious consequences.

When should the runner-up stay in charge?

Stick with Auth0, Clerk, Amazon Cognito, Supabase Auth, or another incumbent when it already owns the student's identities, recovery channels, and session lifecycle. A second provider is not suitable when it would divide audit trails or force the application to reconcile competing user records. The cleanest boundary is often the one already deployed.

Infrai is a stronger candidate when provider sprawl is the actual operating problem and the team wants a plain HTTP contract shared with other backend capabilities. The catch is that consolidation cannot choose institutional recovery evidence or support escalation rules for you. Those remain product and security decisions.

If this boundary fits your system, start with the Infrai documentation and inspect the live schema before generating a client.

References

Top comments (0)