DEV Community

JasperFlint6947
JasperFlint6947

Posted on

Password Recovery Design: Anonymous Requests, Verified Resets, and Session Revocation

Short answer: make the forgot-password endpoint behave the same for a known and unknown address, then let a single-use, short-lived token move the account into a confirmed reset state before revoking sessions. For a marketplace migrating off a managed auth provider, keep that state machine in your application database and keep email delivery behind a replaceable interface.

I build Python services that move from notebook experiments to production, so I care about two things in this flow: an evaluator can prove each transition, and a retry cannot spend a token twice. Bot signups and forgotten passwords are different abuse problems, but they meet at the same trust boundary: an email address is not proof of identity until the user completes a challenge.

The pipeline starts with an intentionally boring response

The request flow is small enough to draw on a whiteboard. A browser posts an email address. The API normalizes it, records a hashed recovery request, and queues a message if an account matches. The HTTP response is identical either way. A link carries a random token, never a user id or email in the URL. The reset endpoint hashes the token, consumes it in one transaction, changes the password, and revokes every active session for that account.

That first response is a privacy control. Returning “no such account” turns your signup and recovery endpoints into an address-harvesting oracle. OWASP recommends a consistent message and timing profile for this reason. Exact timing equality is difficult in a distributed system, but the observable work should be comparable and the response text should never reveal account existence.

The state model I use is explicit: requested, delivered, confirmed, completed, and expired. confirmed means the token check succeeded; it does not mean the password has changed yet. Keeping that distinction makes retries and audit queries understandable.

Keep it boring.

Here is a runnable, framework-neutral core. The repository and mailer are intentionally tiny interfaces; a migration can implement them with the database and delivery system already in use.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets


@dataclass(frozen=True)
class RecoveryRequest:
    account_id: str
    token_digest: str
    expires_at: datetime
    used_at: datetime | None = None


def normalize_email(value: str) -> str:
    return value.strip().casefold()


def digest_token(token: str, pepper: bytes) -> str:
    return hmac.new(pepper, token.encode("utf-8"), hashlib.sha256).hexdigest()


def request_reset(email: str, accounts, recovery_repo, mailer, pepper: bytes) -> None:
    address = normalize_email(email)
    account = accounts.find_by_email(address)
    if account is None:
        # Keep the same outward behavior for unknown addresses.
        return

    raw_token = secrets.token_urlsafe(32)
    recovery_repo.invalidate_open(account.id)
    recovery_repo.insert(
        RecoveryRequest(
            account_id=account.id,
            token_digest=digest_token(raw_token, pepper),
            expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
        )
    )
    mailer.enqueue_reset(address, raw_token)


def complete_reset(raw_token: str, new_password: str, recovery_repo, accounts, sessions, pepper: bytes) -> bool:
    now = datetime.now(timezone.utc)
    digest = digest_token(raw_token, pepper)
    request = recovery_repo.lock_unused(digest)
    if request is None or request.expires_at <= now:
        return False

    account = accounts.get(request.account_id)
    accounts.set_password(account.id, new_password)
    recovery_repo.mark_used(request.token_digest, now)
    sessions.revoke_all(account.id, reason="password_reset")
    return True
Enter fullscreen mode Exit fullscreen mode

The repository method named lock_unused must take a row lock or use an atomic update such as UPDATE ... WHERE used_at IS NULL. Otherwise two parallel requests can both pass the check. Password hashing belongs inside accounts.set_password; use a memory-hard password hash configured for your risk budget, and never store the reset token itself.

One short sentence matters here: the link is a bearer credential.

How should a password recovery pipeline handle neutral requests and session cleanup?

Neutral requests are only useful if every nearby endpoint follows the same rule. The signup form, login errors, and reset request should avoid account enumeration. Rate-limit by IP, account key, and a coarse device signal, but do not make the unknown-address branch visibly cheaper. Queue work so a busy mail provider does not turn the endpoint into a timing oracle.

Confirmation needs a clean boundary. A GET that renders the reset page should not consume the token; link scanners and mail security tools often fetch links automatically. Consume it on the authenticated POST that supplies the new password. Bind the form to a CSRF token, require a password policy that your users can understand, and write an audit event with a request id, account id, and outcome. Never put the raw token in that event.

Session cleanup is broader than deleting one cookie. Revoke server-side sessions, refresh tokens, remembered devices, and any password-derived API credentials. Existing access tokens may be self-contained JWTs, so either keep their lifetime short or check a per-account credential version on each request. The exact choice depends on latency and storage, but the decision must be written down and tested.

I keep the reset state separate from the session store because it makes evaluation easier. In a test harness, I can replay requested -> confirmed -> completed, submit the same token twice, advance the clock beyond 15 minutes, and assert that only one password write happened. That is more useful than a happy-path browser test.

Failure modes that appear during a provider migration

Migration projects often copy screens and forget invisible guarantees. A managed provider may have hidden throttles, email templates, token storage, and session revocation semantics. Before switching, capture those behaviors as contract tests. The replacement is ready only when the tests show equivalent security properties, not when the new form looks familiar.

The most common mistakes are mundane:

  • Comparing raw emails case-sensitively, creating duplicate recovery paths.
  • Logging a full link in a reverse proxy or analytics system.
  • Treating a successful token lookup as a completed reset.
  • Revoking only the browser cookie while refresh tokens remain valid.
  • Allowing a second request to invalidate the first without telling the user which message is current.

The last point needs a product decision. Invalidating older links limits the replay window, but users may receive messages out of order. A clear “latest link wins” policy, plus a support-visible audit trail, is usually easier to explain than allowing five active tokens.

Your mileage may vary on token lifetime. Fifteen minutes is a reasonable starting value for an email bearer token, not a universal law; measure delivery latency and abuse pressure, then adjust with evidence.

Evaluating the design before it ships

For an AI-heavy Python team, I put these checks beside prompt and retrieval evaluations. The same discipline applies: define an invariant, generate awkward inputs, and inspect the trace rather than trusting a green UI test.

Use properties such as:

  1. Known and unknown addresses produce the same status, body shape, and generic message.
  2. A token can transition to completed at most once, even under concurrent requests.
  3. An expired or used token never changes a password.
  4. Completing a reset revokes all sessions and emits one auditable event.
  5. No log, metric label, URL, or exception contains the raw token.

Add tests for Unicode and whitespace normalization, duplicate email records, mail queue delays, database retries, and clock skew. Inject a fake clock; sleeping in tests hides race conditions and makes suites expensive. Fuzz the token parser with malformed base64 and oversized input. A 400 for malformed input is fine, but it must not reveal whether the account exists.

Observability should expose counts and durations, not secrets: reset_requested, reset_confirmed, reset_completed, reset_expired, and reset_rejected. Alert on a sudden rise in rejected confirmations or requests per account. Keep retention short for identifiers, and document who can inspect the audit stream.

Choosing the boundary you can operate

The application-owned state machine is a good fit when you are migrating providers, need portable data, or want deterministic tests. It costs you operational work: key rotation, email deliverability, abuse throttling, password-hash upgrades, and on-call ownership become your team's responsibilities.

Staying with a managed recovery flow is sensible when compliance evidence, global mail delivery, or a small operations team outweighs portability. It is a poor fit when you cannot export recovery and session state, cannot observe token use, or need a custom marketplace risk policy. In those cases, choose an implementation that exposes those controls, even if the migration takes longer.

The catch is that “self-hosted” does not automatically mean more private or more reliable. It simply moves the trust and maintenance boundary. Write the boundary into your threat model, run the contract tests during migration, and make the rollback path explicit.

Before launch, walk the flow with a real mailbox, a link scanner, two simultaneous POSTs, and an already-authenticated session on another device. Check the audit record, the queue retry, and the revocation result. Then document the one sentence support should use: “Request a new link; only the newest unexpired link can complete the reset.”

References

Top comments (0)