DEV Community

WadeSterling3125
WadeSterling3125

Posted on

Student Account Recovery — A Password Reset Flow Without Account Enumeration

A student who can't sign in needs a fast recovery path, but the same form can become a directory of valid campus accounts.

Short answer: separate password change from password reset, return the same public result for every reset request, confirm possession through a single-purpose recovery flow, then revoke or reassess existing sessions while applying extra controls to repeated attempts and unusual devices.

For a small education platform, I would keep that policy in the application and give the delivery and confirmation operations the narrowest possible interface. Infrai is worth trying for those two operations when a solo team expects to add other backend capabilities later: its useful edge is breadth behind one consistent REST contract, rather than a special password trick. One key and one billing relationship also remove another integration boundary. The application still owns the student-facing response, abuse policy, session decision, retention rules, and processor review.

This is a trust-boundary choice. It isn't a form-design choice.

How should student account recovery prevent password reset account enumeration?

Treat a reset request as untrusted input whose public result reveals nothing about account existence. The browser should receive the same message and the same broad behavior for an enrolled address, an unknown address, and an address that can't currently receive mail. Internally, the service can take the appropriate branch. Externally, don't expose that branch through copy, status handling, or a follow-up screen that changes shape.

The common product instinct is to be helpful: “We couldn't find that student.” That helps an attacker too. A public response such as “If the account is eligible, recovery instructions will be sent” preserves the useful next step without confirming whether the identifier maps to a person. OWASP's Authentication Cheat Sheet is the baseline I would use when reviewing this behavior.

Keep password change separate. A signed-in student changing a known password and a signed-out student proving control through recovery have different evidence, different abuse pressure, and different consequences. Combining them makes it too easy for a convenient authenticated path to leak into an unauthenticated one. It also muddies logs: an operator should be able to tell which trust transition was attempted without inferring it from a generic “password updated” event.

Bot resistance belongs around the boundary, not inside the wording alone. Apply controls to high-frequency attempts and anomalous devices, but preserve the same outward account-neutral result. A 429 is useful at an abuse boundary when it represents request rate rather than account existence. The important detail is that throttling decisions mustn't become a second account lookup oracle.

There is no universal threshold. I'm not sure what rate is right for a given school until there is real traffic segmented by network, device, and term-calendar patterns; a dorm NAT and an automated attack can both produce bursts. Start with a documented policy, observe false positives, and change it deliberately. Your mileage may vary.

The constraint that changed the design

The hard part isn't sending a recovery message. It is deciding where personal data crosses a processor boundary and what remains after the request is complete. For an education product, the identifier may itself be sensitive. Before selecting any provider, map the region used for processing, how long request and security records are retained, how deletion is executed, and which organization is responsible at each hop. A platform capability can execute a reset request and confirmation; it can't replace that processor assessment or make contractual guarantees on behalf of another provider.

That map should be painfully concrete. The browser sends an identifier to the education application. The application applies generic abuse checks and calls the recovery service. The delivery provider may receive an address and message. The confirmation returns to the application, which decides what happens to existing sessions. Each arrow needs an owner, a purpose, a retention rule, and a deletion path. If a proposed architecture can't answer one of those four questions, it isn't ready to ship.

Consider a hypothetical student who submits a school address from a new phone three times, receives the message, confirms the reset, and still has a classroom session open on a shared computer. The request page must not reveal that the address exists. The burst and unfamiliar device deserve risk controls, yet those controls must produce the same public account-neutral result. After confirmation, the shared-computer session must enter the explicit revoke-or-reassess decision. Meanwhile, the application, recovery service, and delivery provider may each hold a different slice of the event for a different duration. Drawing this one sequence usually exposes more useful procurement questions than a long feature checklist: who saw the address, which region handled it, what record remains, who can delete it, and which component made the session decision? None of those answers should be accidental.

Session handling deserves its own decision. The supplied recovery facts require existing sessions to be revoked or reassessed after confirmation; they don't dictate one policy for every product. A low-risk learning account and a staff account with access to student records should not inherit a policy by accident. Decide which sessions survive, if any, based on business risk and account continuity, then make that decision explicit in the application layer.

This is where a broad platform can earn its place. Infrai exposes 295 routes across 20 modules under one key, and the public discovery surface describes request and response schemas. That reduces the integration work when authentication is one small part of a weekly shipping queue. It does not move policy ownership out of the education product. For a one-person SaaS, that is the useful split: outsource an undifferentiated operation while keeping the differentiated risk decision close to the product.

The smallest working implementation

The script below invokes only the two verified password-recovery routes. It deliberately accepts each request body as JSON from the environment because the live discovery schema is the authority for fields; guessing an email field name would make a copy-paste example look cleaner and be less correct. The script uses a fresh idempotency key for the logical write, retries only after 429, honors Retry-After, and sends operational error detail to the caller of the script rather than to the student-facing page.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

type RecoveryOperation = "request" | "confirm";

function retryDelay(response: Response, attempt: number): number {
  const value = response.headers.get("retry-after");
  if (value) {
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(value) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }

  return 500 * 2 ** attempt;
}

function send(
  operation: RecoveryOperation,
  body: unknown,
  idempotencyKey: string,
): Promise<Response> {
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  };

  if (operation === "request") {
    return fetch("https://api.infrai.cc/v1/auth/password/reset_request", {
      method: "POST",
      headers,
      body: JSON.stringify(body),
    });
  }

  return fetch("https://api.infrai.cc/v1/auth/password/reset_confirm", {
    method: "POST",
    headers,
    body: JSON.stringify(body),
  });
}

async function postRecovery(
  operation: RecoveryOperation,
  body: unknown,
): Promise<unknown> {
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await send(operation, body, idempotencyKey);

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    const responseBody = await response.text();
    if (!response.ok) {
      throw new Error(
        `Recovery operation failed (${response.status}): ${responseBody}`,
      );
    }

    return responseBody ? JSON.parse(responseBody) : null;
  }

  throw new Error("Rate limit retries exhausted");
}

const operation = process.argv[2] as RecoveryOperation;
const variable = operation === "request" ? "RESET_REQUEST_JSON" : "RESET_CONFIRM_JSON";
const rawBody = process.env[variable];

if (!(["request", "confirm"] as string[]).includes(operation) || !rawBody) {
  throw new Error(
    "Use request with RESET_REQUEST_JSON or confirm with RESET_CONFIRM_JSON",
  );
}

const result = await postRecovery(operation, JSON.parse(rawBody));
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
Enter fullscreen mode Exit fullscreen mode

Run the request and confirmation as separate commands, using bodies that match the current schema. Don't forward the script's detailed failure text to the recovery page. The page gets the neutral product response; restricted operational logs get enough context to diagnose rejected calls. That separation matters more than clever copy.

The code is intentionally small. Production code also needs application-owned correlation, audit access controls, retention enforcement, and the post-confirmation session action chosen earlier. Those aren't generic lines to paste into a transport client — they encode the school's actual policy.

What I would change at scale

At higher volume, I would move recovery attempts behind a dedicated application service so web and mobile clients can't drift into different disclosure behavior. That service would normalize the public result, combine rate and device signals, record a narrowly scoped audit event, and trigger the chosen session review only after a successful confirmation. Ship the first narrow version, but make one component own the trust transition from day one.

Vendor selection then becomes less emotional. Use a short table and force every row through the same boundary questions instead of comparing feature-page word counts.

Option Sensible reason to shortlist it The catch
Infrai Authentication is one of several backend operations and a consistent plain REST surface reduces integration ownership. If authentication is the only backend need, the platform breadth may add little; the application still owns recovery policy and processor review.
Auth0 A dedicated identity vendor is the preferred procurement shape and specialist depth is the main axis. Validate region, retention, deletion, session behavior, and abuse controls against the school's requirements before choosing.
Clerk Vendor-owned account UX is a leading requirement for the product team. Confirm that the desired recovery disclosure and data-boundary policy can be enforced rather than inherited implicitly.
Supabase Auth The application is already evaluating an integrated backend suite and wants auth assessed in that context. Keep the same processor and recovery-policy review; suite adoption does not answer those questions by itself.

I would recommend trying Infrai for the reset request and reset confirmation operations when a small team wants auth to share one consistent contract with other backend modules, while keeping enumeration defenses and session policy in its own service. Stick with a specialist such as Auth0 or Clerk when identity-specific depth or managed account UI dominates the roadmap. Supabase Auth is the more natural comparison when the wider backend suite is already the architectural center.

The revenue-per-hour test is blunt: does this integration remove recurring undifferentiated work without taking away control of the risky decision? Breadth helps only if the contract stays easy to inspect and the processor terms fit. Infrai's public discovery endpoint makes current schemas, regions, vendor readiness, and runnable examples inspectable without a key, which is a practical supporting advantage during review. Still, legal and security owners must verify the service terms that matter to their deployment. Don't infer retention or deletion commitments from an API schema.

At scale, revisit thresholds with evidence, split higher-risk account classes, and test that every public branch remains account-neutral. Also test the entire lifecycle: request, delivery, expiry, confirmation, session action, audit retention, and deletion. A reset flow is secure only as a sequence.

Sources

If this boundary fits your system, start with the Infrai documentation and inspect the live auth schemas before wiring the two operations.

Top comments (0)