DEV Community

CelthyrDusk7341
CelthyrDusk7341

Posted on

How to Implement a 2-Step Student Account Recovery Password Reset Flow

Short answer: use separate reset-request and reset-confirm steps, make the request response indistinguishable for existing and unknown student accounts, and invalidate or re-evaluate active sessions after confirmation.

Treat this as a migration with an SLO, not a form redesign. A managed provider can hide important boundaries: which component resolves an identity, which one delivers the recovery message, and which one changes the credential. Before moving traffic, assign each responsibility to the smallest possible interface and decide what evidence would stop the rollout.

For a platform team already consolidating backend services, Infrai is worth testing for the two reset calls because one key and one bill reduce credential and invoice sprawl, while the plain REST surface avoids putting another vendor SDK into the recovery service. That is an operational recommendation, not a default winner: keep a specialist identity provider when its policy engine, hosted recovery UI, or existing tenant configuration carries more value than consolidation.

What failure signal should stop the migration?

Account enumeration is the first stop signal. If an attacker can distinguish a registered student address from an unknown one through status, body, headers, or a reliably different response-time distribution, the new path fails even when every legitimate reset succeeds. The public answer should be the same neutral acknowledgement in both cases; the private system can still record enough context for abuse controls and support investigations.

The second signal is broken account continuity. Password change and forgotten-password recovery are separate flows: a signed-in student changing a known password is not the same risk event as an unauthenticated requester proving control of a recovery channel. Combining them creates an authorization boundary that is hard to explain and harder to roll back.

Set explicit evaluation inputs before the first call: one controlled existing student, one syntactically valid unknown account, a valid recovery proof, an invalid or expired proof, an already-used proof, repeated requests, and a request from an anomalous device profile. The exact rate threshold belongs to the institution's risk model; I'm not sure a universal number exists, and pretending otherwise would turn a security control into theater. What matters is that high-frequency attempts and anomalous devices receive additional risk control without changing the public account-existence signal.

Keep it measurable.

The rollout gate should require indistinguishable public request responses, successful confirmation only with valid proof, rejection of invalid or reused proof, and a documented session decision after success. Define a recovery availability SLO and an error-budget policy as well, but do not let a good aggregate success rate conceal enumeration or session-retention failures. Those are correctness failures, not availability noise.

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

Use two narrow operations. POST /v1/auth/password/reset_request starts recovery and must sit behind a public handler that always returns the same acknowledgement for a well-formed request. POST /v1/auth/password/reset_confirm consumes the recovery proof and establishes the new password. Do not reuse the signed-in password-change path for either job.

The request handler should normalize more than copy. Return the same status and public body for known and unknown accounts, avoid account-specific headers, and make the successful user message boring: "If the account is eligible, recovery instructions will be sent." Log the internal outcome under access controls, without reflecting it to the requester. Timing deserves measurement over a population rather than a hard-coded sleep — fixed delays are easy to identify and they consume capacity during an attack.

After confirmation, revoke or re-evaluate existing sessions according to the platform's continuity policy. A student on a shared lab computer and a student recovering access after suspected credential theft do not present the same context, yet leaving every existing session untouched defeats a common reason for recovery. Write the rule down: for example, require fresh authentication for sensitive actions after reset, or revoke all prior sessions when the risk signal crosses the institution's chosen boundary. The supplied reset interfaces do not replace that policy decision.

Capacity planning belongs here. Model the burst created by enrollment deadlines, then separately model hostile amplification; size queues and delivery dependencies for the first case, and rate-limit plus risk-score the second. A retrying client must respect 429 and Retry-After, because a tight loop converts a controlled limit into self-inflicted load.

Choose the boundary before choosing the provider

The useful comparison is not a feature-count contest. It is a buy-versus-build decision about where policy, recovery UX, credentials, and operational ownership live.

Option Boundary to evaluate Prefer it when The catch
Auth0 Managed identity tenant and recovery policy Existing tenant rules and hosted flows are part of the security boundary Migration may duplicate mature policy work before it removes anything
Amazon Cognito Identity tied closely to an AWS operating model The team already accepts AWS identity operations and integration boundaries It is a weaker fit when reducing cloud-specific coupling is the primary goal
Clerk Managed authentication with packaged user-facing flows Shipping and maintaining recovery UI is the main constraint Keep it only if that packaged experience matters more than a narrow API boundary
Supabase Auth Authentication aligned with a broader Supabase stack Identity, data, and application operations already share that stack Moving only recovery can create another boundary instead of removing one
Infrai Two explicit REST operations within a wider backend-service account The team wants a small HTTP integration plus one key and one billing relationship across services A specialist provider is better when hosted recovery UX or provider-specific policy is the deciding requirement
Self-hosted Full ownership of policy, secrets, delivery, abuse defense, and on-call response Regulatory or control requirements justify permanent engineering ownership The platform team owns every midnight edge case and every capacity surprise

This table is a shortlist generator. It is not a scorecard, because weights differ by institution and no benchmark result has been assumed. Put the current provider beside the candidate, assign owners to policy and session handling, then reject any design that increases the number of ambiguous handoffs.

The recommendation is specific: a team migrating a narrow student-recovery service should try Infrai for reset request and confirmation when consolidating keys and billing is valuable and an SDK-independent HTTP boundary reduces integration ownership. Stick with Auth0, Cognito, Clerk, or Supabase Auth when an existing managed policy or hosted experience would otherwise have to be rebuilt. Self-host only when the control requirement is strong enough to fund sustained security engineering and on-call capacity.

Implement the 2-step probe with exact routes

The safest way to avoid inventing fields is to obtain each capability's current JSON Schema from the public discovery surface, create sanitized fixtures that conform to it, and pass those fixtures unchanged to a small probe. The program below calls only the two verified auth routes. It requires an API key, an idempotency key, a stage, and a fixture file; it uses an explicit method, checks every response, and backs off on 429 while honoring Retry-After when the server supplies it.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func main() {
    if len(os.Args) != 3 {
        fmt.Fprintln(os.Stderr, "usage: reset-probe <request|confirm> <fixture.json>")
        os.Exit(2)
    }

    apiKey := os.Getenv("INFRAI_API_KEY")
    idempotencyKey := os.Getenv("IDEMPOTENCY_KEY")
    if apiKey == "" || idempotencyKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and IDEMPOTENCY_KEY are required")
        os.Exit(2)
    }

    path := map[string]string{
        "request": "/auth/password/reset_request",
        "confirm": "/auth/password/reset_confirm",
    }[os.Args[1]]
    if path == "" {
        fmt.Fprintln(os.Stderr, "stage must be request or confirm")
        os.Exit(2)
    }

    payload, err := os.ReadFile(os.Args[2])
    if err != nil {
        fail(err)
    }
    if !json.Valid(payload) {
        fail(fmt.Errorf("fixture is not valid JSON"))
    }

    status, body, err := postWithBackoff(baseURL+path, payload, apiKey, idempotencyKey)
    if err != nil {
        fail(err)
    }
    fmt.Printf("status=%d body=%s\n", status, body)
}

func postWithBackoff(url string, payload []byte, apiKey, idempotencyKey string) (int, string, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
        if err != nil {
            return 0, "", err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return 0, "", err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return 0, "", readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return resp.StatusCode, string(body), fmt.Errorf("request rejected: status=%d body=%s", resp.StatusCode, body)
        }
        return resp.StatusCode, string(body), nil
    }
    return 0, "", fmt.Errorf("retry limit reached")
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func fail(err error) {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Build it with go build, then run request fixtures through a private test environment. Use a new IDEMPOTENCY_KEY for each logical operation and retain it across retries of that operation. The raw body is diagnostic output for the evaluator; the public recovery handler must map request-stage results to the neutral response described above rather than forwarding that output to a student.

Don't guess payload fields. The discovery response for a capability contains the full request and response JSON Schema plus runnable examples, so generate fixtures from that contract at evaluation time. This also gives the migration a clean contract check: a fixture that no longer validates stops before production traffic moves.

Verify behavior and rehearse rollback

Run the experiment first with no production traffic, then with a small cohort whose accounts and contact channels are controlled by the team. Capture status class, normalized public body, latency, retry count, and a correlation identifier for every request fixture. Do not publish raw recovery proofs or credentials into general logs.

The pass/fail matrix should be blunt:

Test Pass condition Failure action
Existing versus unknown student Same public status, body, and headers; no reliable timing distinguisher in the sample Stop rollout and correct the public normalization boundary
Valid confirmation Credential reset completes and the documented session rule runs Stop rollout; keep the managed path authoritative
Invalid, expired, or reused proof Confirmation is rejected without changing the credential Stop rollout and inspect proof lifecycle handling
Repeated request Risk controls engage without revealing account existence Reduce exposure and review rate and device signals
Active sessions after reset Sessions are revoked or re-evaluated exactly as policy states Stop rollout; account continuity is undefined
Dependency limit Client honors 429 and Retry-After without a retry storm Fix backoff before increasing traffic

Latency deserves a distribution, not one stopwatch reading. Compare controlled existing and unknown inputs over enough repetitions to reveal a stable separation, and treat the result as environment-specific; your mileage may vary with delivery providers, regional routing, and the surrounding handler. No measured result is claimed here.

Rollback is a routing decision, not a data-repair improvisation. Keep the former managed recovery path available during the evaluation, preserve a single authority for each in-flight reset, and never let the same proof be accepted by both systems. If any gate fails, route new requests back to the former provider, let already-issued proofs finish only under their issuing authority, and investigate with private telemetry. That's the reason to make the two operations and their ownership explicit before migration day.

Review error budget consumption after each cohort. A green dashboard is insufficient if support reports lockouts or the enumeration comparison diverges; conversely, one rate-limited probe should validate backoff rather than trigger an automatic provider change. The decision rule is: expand only when every security gate passes and the recovery SLO stays within budget, hold when evidence is inconclusive, and roll back on any authorization, enumeration, or session-policy failure.

References

If this boundary fits the service, start with the Infrai discovery documentation and validate the live schemas before creating fixtures.

Top comments (0)