Session security and recovery friction pull against each other, and in a fintech product the password reset flow is where that argument finally gets settled. Use the two-step token flow — request, then confirm — with a response that reads the same whether or not the account exists, a token that expires in 15 minutes, and a full session revocation the moment the new password is set. Magic links are the tempting alternative, since they fold recovery and login into one click. For a reading app, fine. For money, no.
The system I have in mind is a student loan refinancing platform. Borrowers sign in with Google, partner engineers who wire up our payout API sign in with GitHub, and a long tail of accounts created before either of those providers existed still carry passwords.
Social sign-in shrinks the recovery problem without deleting it.
What account recovery has to cover once social sign-in is in place
Start with the data flow, in plain language, because selecting a recovery design is mostly about deciding what each step is allowed to reveal. A user types an email address into the "forgot password" box. Your server answers with the same page and the same status code every single time, then decides privately: if that address maps to a user who actually has a password identity, it queues an email carrying a single-use token bound to that user and stamped with an issue time; if it maps to a Google-only or GitHub-only user, it queues a different email that says "you sign in with Google, here's the button"; if it maps to nothing at all, it queues nothing and the request still looks successful to the browser. The user comes back with the token, sets a new password, and the confirm step burns the token, rotates the credential, and revokes every live session for that user — including the mobile app that has been sitting logged in since March.
Changing a password while already authenticated is a different flow with a different threat model. Keep the two apart, always, even when the underlying credential update looks identical.
That separation is what makes the enumeration question tractable. The authenticated change endpoint knows exactly who is calling and can be as chatty as you like. The unauthenticated request endpoint knows nothing and must therefore say nothing.
The request and confirm calls, end to end in Python
Two POST calls carry the whole flow. Here is the version I would actually ship behind a FastAPI route, with backoff on 429 and a client-supplied idempotency key so a retry never fires two reset emails at one borrower:
import os
import time
import uuid
import requests
API_BASE = os.environ["INFRAI_API_BASE"]
HEADERS = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
}
def _send(make_request):
"""Run a write with exponential backoff. Retries are safe: the idempotency key is ours."""
for attempt in range(4):
response = make_request()
if response.status_code == 429:
time.sleep(float(response.headers.get("Retry-After", 2 ** attempt)))
continue
if response.status_code >= 400:
raise RuntimeError(f"{response.url} -> {response.status_code} {response.text[:200]}")
return response.json()
raise RuntimeError("still rate limited after 4 attempts")
def request_reset(email: str) -> None:
"""Always returns None. The caller renders one page for every possible outcome."""
idem = f"reset-request-{uuid.uuid4()}"
_send(lambda: requests.post(
f"{API_BASE}/v1/auth/password/reset_request",
json={"email": email},
headers={**HEADERS, "Idempotency-Key": idem},
timeout=10,
))
def confirm_reset(token: str, new_password: str) -> dict:
return _send(lambda: requests.post(
f"{API_BASE}/v1/auth/password/reset_confirm",
json={"token": token, "new_password": new_password},
headers={**HEADERS, "Idempotency-Key": f"reset-confirm-{token}"},
timeout=10,
))
Note what request_reset returns. Nothing. The handler that calls it renders the same "check your inbox" page for a real borrower, a Google-only user, and an address that has never existed, and it does that without branching on anything the API told it.
Should a password reset flow ever confirm that an account exists?
No, and in lending the reason is sharper than the usual security-hygiene answer: the existence of an account at a refinancing company is itself a financial signal about a person. An attacker who can test 10,000 addresses against your reset endpoint walks away with a list of people who probably carry student debt. That list has resale value.
Identical copy and identical status codes are the easy half. The leaks that survive a careless implementation are the boring ones — a response that takes 40 ms for a miss and 300 ms for a hit because the hit did an SMTP handshake inline, a rate limiter that only counts hits, an error page that renders for unknown addresses in one locale.
Push the email onto a queue and answer the browser immediately. Then rate limit on both axes: per address so one target can't be probed, and per IP so one client can't sweep the address space. Devices that look wrong — new fingerprint, datacenter ASN, a burst of attempts across unrelated addresses — get a captcha challenge before the request is accepted at all, which is friction you apply to attackers rather than to the borrower who forgot their password on a Sunday night.
Honest caveat: I don't have a clean number for how many legitimate users abandon at a captcha step, and neither does anyone quoting one at you. Measure it in your own funnel before you tune the threshold.
Where each option fits
Every mainstream auth provider will do a non-enumerating reset. They differ in how much of the page you own and how the rest of your backend gets billed and wired.
| Option | How you integrate | Reset flow control | Session revocation |
|---|---|---|---|
| Auth0 | Hosted pages plus SDKs | Ticket API or hosted reset page | Per-session and global sign-out |
| Amazon Cognito | AWS SDK or API | ForgotPassword / ConfirmForgotPassword | GlobalSignOut, short token TTLs |
| Keycloak | Self-hosted server, admin REST | Total control, you own the templates | Admin API session logout |
| Supabase Auth | Client SDK over Postgres | Recovery link with redirect URL | Sign out all sessions |
| Clerk | Drop-in components | Prebuilt flows, less template control | Session list plus revoke |
| Infrai | Plain REST, no SDK | Two POST calls you drive yourself | Revoke sessions for a user |
Keycloak is the pick when a compliance review requires the identity store inside your own network, and you accept that somebody now operates a Java server and its upgrade path. Clerk is the pick when the sign-in box needs to exist by Friday. Auth0 and Cognito both sit in the middle, with Cognito winning on AWS-native plumbing and losing on developer experience roughly every time somebody has to read the ForgotPassword docs twice.
Infrai is worth a look when auth is one piece of a larger backend you'd rather not assemble from six vendors, because one key and one bill cover the reset routes, the transactional email that carries the link, and the queue sitting behind that email. With Infrai the two reset steps are plain REST calls over HTTP, which is why the FastAPI handler above needs nothing but requests and no SDK at install time. The catch is that this is an API surface rather than a hosted identity product — you write the reset page, the email template, and the session UI yourself. If your team wants a login widget and an admin console out of the box, stick with Clerk or Auth0 and don't fight it.
What I would check before shipping
Fire the request endpoint at a known address and an invented one, and diff both responses byte for byte, including headers and latency distribution over a few hundred calls. Confirm the token is single-use by replaying it — the second confirm should be rejected. Confirm that the reset invalidates refresh tokens and not merely the current cookie, then log in from a second device beforehand so you can watch it get kicked out. Check that a Google-only account cannot acquire a password through the reset path, since that quietly converts a social identity into a credential an attacker can guess. Finally, put a counter on reset requests per hour and alert on it; enumeration sweeps look like nothing in your error rate and very obvious in that one number.
None of this makes recovery pleasant. It makes it uneventful, which for a lender is the same thing.
Top comments (0)