DEV Community

BriarVoss47291
BriarVoss47291

Posted on

FastAPI Password Reset Loop Control Without Leaking Account Existence

Short answer: break a password reset loop by making the request endpoint return one generic result, representing reset progress as an explicit state machine, consuming every token at most once, and invalidating existing sessions after success; don't let the browser infer whether an account exists from text, status, timing, or redirects.

That decision matters during a migration off a managed identity provider. A logistics control plane may have dispatchers signed in on shared workstations, drivers on phones with intermittent connectivity, and an operations team that must revoke a stolen session now. The reset screen cannot become an account directory, but it also cannot bounce a legitimate user between “request,” “expired,” and “sign in” forever. Treat those as two separate jobs: a public recovery workflow with deliberately uniform output, and a private credential transition that rotates refresh credentials and revokes sessions.

The data flow is small enough to reason about. A browser submits an identifier; the API normalizes it, performs the same public-facing work, and always acknowledges the request. If a matching account exists, a private worker sends a single-use token. Redeeming that token changes the credential, marks the token consumed, increments a server-side session epoch, and replaces the refresh-token secret. The UI then lands on one terminal result instead of automatically starting recovery again.

How Can FastAPI Break Password Reset Loops Without Leaking Account Existence?

Start with the response contract. OWASP recommends generic authentication responses so that login, password recovery, and account creation do not reveal whether an account exists. It also warns that different processing time can create the same leak even when the words match. For the reset-request endpoint, use the same HTTP status and response body for known accounts, unknown accounts, and repeated requests. Keep mail delivery and account-specific mutations beyond that public boundary.

The loop itself is usually a state-model problem. A client sees an invalid or consumed token, interprets that result as “request another token,” redirects to the request form, and then a sign-in guard sends it back to recovery. Nothing owns the terminal state. Model request_accepted, reset_succeeded, and reset_unavailable explicitly. Only a human action should move from the last state to a fresh request.

No automatic retry.

Internal condition Public state Allowed next move
Known or unknown identifier request_accepted Wait or manually start a new request
Valid single-use token reset_succeeded Sign in with the new credential
Invalid, expired, or consumed token reset_unavailable Manually start a new request

There is one subtle distinction here — uniformity must cover account existence, not erase every meaningful state after the user presents a secret. The request endpoint should reveal nothing about the submitted email. The redemption endpoint may say that a reset link cannot be used, but it should not explain whether it was unknown, expired, already consumed, or tied to a disabled account. That single terminal message prevents both enumeration and branch-heavy UI behavior.

A Runnable FastAPI Reset Flow

This compact example keeps storage in memory so the transition is visible. Production storage needs an atomic compare-and-set for token consumption and durable session state, but the HTTP contract stays the same. The token sent to the user is random; only its digest is stored. Successful redemption changes the password digest, consumes the reset record, advances session_epoch, and rotates refresh_secret, which gives the logistics service one place to reject a stolen pre-reset session.

from __future__ import annotations

import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass, field

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, Field


app = FastAPI()
RESET_TTL_SECONDS = 15 * 60
DUMMY_SALT = b"reset-request-equalizer"


def digest(value: str) -> str:
    return hmac.new(DUMMY_SALT, value.encode(), hashlib.sha256).hexdigest()


def password_digest(password: str) -> str:
    salt = secrets.token_bytes(16)
    derived = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 200_000)
    return f"{salt.hex()}:{derived.hex()}"


@dataclass
class User:
    user_id: str
    email: str
    password_hash: str
    session_epoch: int = 0
    refresh_secret: str = field(default_factory=lambda: secrets.token_hex(32))


@dataclass
class ResetRecord:
    user_id: str
    expires_at: float
    consumed: bool = False


users_by_email = {
    "dispatcher@example.test": User(
        user_id="usr_dispatch_17",
        email="dispatcher@example.test",
        password_hash=password_digest("replace-this-bootstrap-password"),
    )
}
resets_by_digest: dict[str, ResetRecord] = {}
outbox: list[tuple[str, str]] = []


class ResetRequest(BaseModel):
    email: EmailStr


class ResetSubmission(BaseModel):
    token: str = Field(min_length=32, max_length=256)
    new_password: str = Field(min_length=12, max_length=256)


@app.post("/password-resets", status_code=202)
def request_reset(body: ResetRequest) -> dict[str, str]:
    email = body.email.lower().strip()
    user = users_by_email.get(email)

    token = secrets.token_urlsafe(32)
    token_digest = digest(token)
    if user is not None:
        resets_by_digest[token_digest] = ResetRecord(
            user_id=user.user_id,
            expires_at=time.time() + RESET_TTL_SECONDS,
        )
        outbox.append((user.email, token))

    # Do equivalent fixed work before returning the same public response.
    hashlib.pbkdf2_hmac("sha256", email.encode(), DUMMY_SALT, 50_000)
    return {"state": "request_accepted"}


@app.post("/password-resets/consume")
def consume_reset(body: ResetSubmission) -> dict[str, str]:
    record = resets_by_digest.get(digest(body.token))
    usable = (
        record is not None
        and not record.consumed
        and record.expires_at >= time.time()
    )
    if not usable:
        raise HTTPException(
            status_code=400,
            detail="This reset link cannot be used. Request a new link to continue.",
        )

    user = next(user for user in users_by_email.values() if user.user_id == record.user_id)
    record.consumed = True
    user.password_hash = password_digest(body.new_password)
    user.session_epoch += 1
    user.refresh_secret = secrets.token_hex(32)
    return {"state": "reset_succeeded"}
Enter fullscreen mode Exit fullscreen mode

The outbox is a stand-in for a private delivery queue, not a pattern for holding mail in application memory. Likewise, a fixed password-derivation call is useful for showing that unknown-account requests should not take a visibly shorter path, but it is not proof of timing equivalence. Queue latency, database indexes, cache hits, and rate-limit branches can still separate the distributions. Measure the entire endpoint from outside the process. If those distributions remain distinguishable, the implementation needs a bounded response strategy based on measurements, not a guessed delay.

The example also makes a policy choice: a password reset revokes all earlier sessions by incrementing an epoch. Each access session should carry the epoch observed at issuance, and authorization should reject it when it no longer matches the user's current epoch. Refresh credentials need the same check. For a stolen dispatcher session, this is stricter and easier to audit than waiting for every access token to expire; the catch is that every legitimate device must sign in again, which may be a poor fit for a low-risk consumer workflow that promises device continuity.

Migrating Without Recreating Provider Coupling

A managed-provider migration gets risky when an application copies provider states directly into its own UI. Names such as “challenge required” or “ticket invalid” may have made sense behind the old SDK, yet they are weak domain concepts. Define a narrow internal port instead: request recovery, consume recovery secret, revoke sessions, and validate session epoch. The FastAPI handlers should depend on that port while adapters translate old and new provider behavior during the migration window. Keep the public state vocabulary smaller than the internal audit vocabulary: the browser needs request_accepted, reset_succeeded, and reset_unavailable, while the restricted security log can distinguish unknown_identifier, expired_token, consumed_token, and session_epoch_advanced, provided those details never return through the public API or appear in user-visible analytics payloads. This separation lets an operations team investigate abuse without turning observability into a side channel. Reset tokens deserve their own migration plan as well. Do not attempt to reinterpret an old provider's opaque reset secret unless its documented contract explicitly permits it. A cleaner boundary is to let already-issued links finish on the old recovery adapter for their normal lifetime while all new requests use the new issuer. During that short overlap, both successful paths must converge on the same local credential-transition transaction: update the credential, consume the recovery record, advance the session epoch, and rotate refresh credentials. Picture the dispatcher who opens an older link after the new request path has gone live: the issuer-specific adapter can validate that link, but it cannot choose a different session policy, preserve an earlier refresh credential, or send the browser to a provider-specific screen. The adapter returns one internal success result, the shared transaction advances the epoch, and the UI reaches reset_succeeded. A second click on either the old or new link reaches reset_unavailable and stops there. One invariant, two temporary entry paths.

Keep it monotonic.

I'm not sure which legacy fields a given migration can preserve without the old provider's documented export contract. That uncertainty should be resolved in a staging export before choosing a cutover design. It should not be papered over with email matching, because identifier normalization, duplicate records, and federated identities can make an apparently obvious mapping unsafe.

Rollback deserves equal attention. Keep provider selection behind server-side routing, never in a browser parameter, and make the reset record identify its issuer internally. Rolling the request path back must not reactivate consumed records or lower the session epoch. If rollback cannot preserve those monotonic properties, pause new recovery requests during the cutover rather than risk accepting the same secret twice.

Testing and Operating the Recovery Boundary

An eval harness for authentication should grade invariants, not screenshots. Send a known email and an unknown email through the request endpoint and compare status, body shape, headers, redirect count, and end-to-end latency distributions. Repeat both through rate limiting. Then redeem one valid token concurrently from two workers and require exactly one credential transition. Exercise expired, malformed, and already-consumed tokens; they should all reach the same public terminal state, with no automatic redirect back into recovery.

For the logistics scenario, add a session-revocation test that starts with two valid devices and one copied refresh credential. Complete a password reset, then verify that all three pre-reset credentials fail the epoch check while a newly issued session succeeds. This is the test that connects account recovery to the stolen-session job. Without it, a green password-change test can hide the more serious outcome: the attacker remains signed in.

Watch the cost shape too. Password hashing and timing equalization consume CPU, mail attempts consume delivery capacity, and anonymous reset requests are attacker-controlled. Rate limits should apply to more than one dimension, such as network source and a privacy-preserving identifier key, while still returning the generic contract. Exact thresholds are workload decisions; use traffic measurements and abuse tests rather than copying a number from another application. It's a security control with a denial-of-service trade-off, not a prompt to maximize work per anonymous request.

The operational checklist is short in prose. Alert on unusual changes in request volume, redemption failure categories, mail-queue age, and session-epoch advances, but keep account-specific reasons in restricted logs. Confirm that logs do not contain raw reset tokens. Practice revoking a stolen session without a password reset, because incident response should not force a dispatcher through recovery when the credential itself is still trusted. Finally, run the enumeration and concurrency evals on every adapter before and after migration. Notebook checks are useful while shaping the contract; the production gate belongs in repeatable tests.

Use this architecture when the application can enforce session epochs at every authorization boundary and can coordinate credential updates atomically. It is not suitable when downstream services validate long-lived tokens entirely offline and cannot observe revocation state. In that environment, stick with shorter-lived access credentials plus an introspection or revocation design that those services can actually enforce before claiming that password reset ends a stolen session.

References

Top comments (0)