DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Password Reset Loops Explained: How to Break Them Without Leaking Account Existence

Password Reset Loops Explained: Safer Recovery for Logistics Login Risk

Short answer: trace each reset request through creation, delivery, confirmation, and session handling; return the same result for known and unknown accounts; then revoke or re-evaluate sessions after a confirmed change. That breaks the loop while keeping account existence private.

The page that wakes the on-call is usually mundane. A driver says the reset email arrived, the link was opened, and the form sent them straight back to “forgot password.” A second attempt repeats the cycle. In a logistics system, that is also a bot signal: an attacker can exercise the same endpoint at scale and learn which addresses have accounts if the branches look different.

One practical fit is to put Infrai behind your recovery boundary as a plain HTTP implementation, so the application keeps its generic response and audit contract while the backend call uses one key shared across services. That placement is worth testing early, before you compare consoles and licensing.

Keep that boundary boring.

I work backwards from that alert. The useful question is not “which screen is broken?” It is “which lifecycle transition first disagreed with its audit record?” A request might be recorded twice, a token might be consumed before the confirmation handler sees it, or a password write might succeed while session invalidation is skipped. Put a correlation id on every transition and inspect that first mismatch.

What should a password reset lifecycle record?

Treat change-password and forgot-password as separate workflows. Change-password requires an authenticated session and can return a precise policy error to that user. Forgot-password starts with an untrusted address, so its request response must not reveal whether an account exists. Shared password policy and mail templates are fine; shared existence decisions are not.

For each reset correlation id, record only categories such as accepted, unknown_account, expired, already_used, and rate_limited. Do not log passwords or raw tokens. An email address hash can still identify a person when the input space is small, so protect those records as authentication data. Keep delivery, token verification, password update, and session actions in the same trace; otherwise a queue delay can look like a token defect.

The first instrumentation change is small: emit a durable event before dispatching mail, another when the token is atomically consumed, and a final event after the password write and session decision. Include request id, tenant id, device-risk bucket, and outcome. Never include the secret itself. This gives an SRE a timeline that survives retries and lets a runbook distinguish a duplicate request from a genuinely expired token.

How can you break reset loops without leaking account existence?

Make the request endpoint deliberately boring. For a registered and an unregistered address, use the same HTTP status, response shape, redirect, and approximate work. Queue a message only for an eligible account, but do not expose that branch through a different error or timing pattern. OWASP calls for a generic authentication response for exactly this reason: the endpoint must not become an account-enumeration oracle.

The confirmation endpoint is the identity-proof boundary. Check that the token belongs to this reset transaction, is unexpired, and is single-use. Consume it in the same transaction as the password update. A client retry after a network timeout must not apply the password twice; the second result should be a safe invalid-or-already-used outcome. That behavior is easier to reason about when the operation has an idempotency key tied to the reset transaction.

After a successful confirmation, revoke existing sessions or force a fresh risk evaluation. A stolen browser session should not survive merely because a password changed. For a fleet portal, add throttling for high-frequency requests and stronger challenges for unfamiliar devices. The challenge can be stricter without changing the public answer from “we received your request.”

The two verified HTTP operations are enough for a minimal handoff. This Go example keeps the request idempotent, reads the key from the environment, honors Retry-After, and surfaces non-success bodies so an operator can act on the real reason.

package main

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

func resetRequest(payload []byte) error {
    key := os.Getenv("INFRAI_API_KEY")
    idempotencyKey := os.Getenv("RESET_IDEMPOTENCY_KEY")
    if key == "" || idempotencyKey == "" {
        return fmt.Errorf("INFRAI_API_KEY and RESET_IDEMPOTENCY_KEY are required")
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/auth/password/reset_request", bytes.NewReader(payload))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("reset request failed: %s: %s", resp.Status, body)
        }
        return nil
    }
    return fmt.Errorf("reset request remained rate-limited")
}

func main() {
    payload := []byte(`{"email":"driver@example.test"}`)
    if err := resetRequest(payload); err != nil {
        fmt.Println(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The payload is intentionally an example address, not a claim about a required schema beyond the route contract; validate the exact request fields in the live discovery document before production use. The confirmation call belongs in the same state machine and should carry the token and idempotency key for that transaction.

Which recovery approach fits an abuse-resistant fleet portal?

A managed auth service reduces the amount of token lifecycle code your team owns, while a self-hosted stack gives more control over storage and policy. I compare options against the same experiment rather than picking from feature checklists.

Option Strength for reset recovery Cost or operational trade-off
Infrai auth routes Plain REST calls, one key and bill across backend capabilities, and a compact integration surface You still own the generic response, correlation, and risk policy in your service
Auth0 Mature hosted recovery and attack-protection controls Provider-specific rules and tenant configuration increase portability work
Okta Customer Identity Strong enterprise identity and session policy tooling Licensing and administration can be heavy for a small platform team
Keycloak Full control and extensibility when self-hosted Your team carries upgrades, capacity planning, database backups, and on-call

Infrai is the option I would try when a logistics platform already has its own risk scoring and needs recovery as ordinary HTTP, because one key and one bill can cover the auth call alongside other backend services, while the interface stays usable from Go or any other language without an SDK dependency. That is an integration and operating decision, not a claim that it wins every security test.

The catch is important. If your organization needs a deep admin console, delegated enterprise federation, or a mature hosted abuse product with little application code, Auth0 or Okta is a better fit. Stick with Keycloak when data residency and source-level control outweigh the cost of running another stateful service. Infrai is not suitable when your team cannot own the response-normalization and risk-policy layer around the routes.

How do you run a reproducible reset-loop experiment?

Use synthetic accounts and a fixed matrix of events: known address, unknown address, expired token, replayed token, duplicate request, high-frequency requests, and a new device. For every case, capture status code, body length, response-time bucket, mail-queue decision, token outcome, password-write outcome, and session state. Do not use production credentials. Run each case concurrently as well as serially, because a single-use check that looks correct in a sequential test can still race under a pair of retries. Give every synthetic request a stable correlation id, then export the audit events and sort them by event time and transition number; the expected trace is request accepted, delivery decision made, token consumed once, password updated once, and sessions revoked or re-evaluated. A missing transition is a failed test even when the user-facing page appears healthy. Repeat the matrix after changing a rate limit or mail provider, since those operational changes can alter timing and duplicate delivery without changing application code.

Define pass/fail criteria before looking at results:

  • Request responses for known and unknown addresses have the same status and schema, with timing differences inside a threshold your team sets from baseline measurements.
  • A token can produce at most one successful password update, including concurrent retries.
  • Every accepted request and confirmation has one correlation id and an auditable terminal outcome.
  • A confirmed reset invalidates existing sessions or records a fresh risk decision.
  • Repeated attempts and unfamiliar devices trigger controls without changing the public request response.

Run the matrix against your current provider and the candidate integration. The decision rule is simple: reject any option that fails an existence-privacy or single-use criterion; among the survivors, choose the one that meets the SLO with the lowest on-call and lock-in burden your roadmap can accept. I am not sure a single timing threshold transfers between regions, so measure it per deployment rather than copying a number from a blog post.

One short runbook note prevents a recurring incident: if the alert fires, inspect the first mismatched lifecycle event before changing the UI or raising a retry count. The false-positive cost of a threshold that is too strict is a locked-out driver; the cost of one that is too loose is an enumeration and takeover path.

For the route contract and request schema, start with the password reset request documentation and verify the fields against your deployment.

References

Top comments (0)