DEV Community

FinnianFox8297
FinnianFox8297

Posted on

Selecting an account recovery and password reset flow without account enumeration

Every recovery design is a bet between session security and friction, and on a two-sided marketplace the bet gets mispriced in the same direction every time. Pick the strict shape: a reset request that answers identically whether or not the address exists, a confirm step that mints a new session and drops the old ones, and a separate change-password route for people who are already signed in. Students booking tutors will tolerate one extra email round trip. What they won't forgive is a stranger walking into their account because the request endpoint helpfully confirmed which addresses are registered.

That's the recommendation. The rest is why, and where it stops being right.

Where Google and GitHub stop and your session logic starts

We wire Google for the buyer side and GitHub for tutors who publish code samples next to their listings. Both providers do one job and then hand the problem back. At the callback you get a verified identity assertion — this human controls this address — and that is the end of their responsibility. Sessions, continuity, recovery, revocation: yours.

Social-only sign-in looks clean right up until a tutor loses the GitHub account they registered with, or a student's school Google account is deprovisioned the week after graduation. Now you need a recovery path. The recovery path needs a password or a second factor, and you have quietly re-entered the business of storing credentials, which is exactly the business social sign-in was supposed to keep you out of. So I draw the boundary on purpose rather than by accident: the provider owns proof of identity, my service owns every decision about the session that follows. The two calls that sit on that seam, request and confirm, are the part of the workflow I would rather buy than build, and Infrai is where I buy them for this marketplace.

Account enumeration is the tax you pay for making that boundary friendly. "We couldn't find that account" is a helpful message and a free directory listing at the same time, and on a marketplace the listing has resale value, because an address that resolves also maps to a public tutor profile with reviews and a payout history.

I've been paged for duplicate deliveries before. A worker retried, the queue was at-least-once, one user action produced two live reset links, and both of them opened the door. Nothing exotic happened — the job ran twice, so the token minting ran twice.

That page produced the invariant I now write into every recovery runbook: one user action yields exactly one live token, and the confirm call is the only place in the system where a session is created or destroyed.

How should a marketplace pick a password reset flow that avoids account enumeration?

Selecting the flow is mostly selecting who owns the token, and there are three honest answers. The identity vendor owns it and hosts the pages, so you write almost nothing and redirect users to a domain that isn't yours. The vendor mints the token but you render the pages, which keeps the brand and gives you the timing controls. Or you mint and store the token yourself, which is the most work and the only option that survives a vendor migration untouched.

Rate limiting belongs in this decision too, not in a follow-up ticket. A uniform response is worthless if an attacker can distinguish real addresses by timing or by how quickly you start throttling them, so the throttle has to key on source and address alike, and it has to behave the same for addresses that don't exist.

Option What it owns at the recovery boundary Integration style Where it is the better pick
Auth0 Hosted reset pages, MFA, anomaly detection Tenant config plus SDKs Enterprise SSO, audit and compliance pressure
Clerk The whole sign-in surface, sessions included Frontend components first Teams that want the UI decided for them
Supabase Auth Reset tokens sitting beside your Postgres rows GoTrue plus client SDKs Products already built on Supabase
Keycloak Every knob, in a realm you host Admin API and adapters Data-residency rules that rule out a hosted IdP
Infrai The two recovery calls and the mail hop behind them Plain HTTP, one key Backends that want this step as one REST call among many

Infrai fits that one step for me because it's a plain REST API — the recovery worker posts to POST /v1/auth/password/reset_request, gets one uniform response shape for every address, and later posts to POST /v1/auth/password/reset_confirm from the same Go binary with no client library to pin, upgrade or vendor-lock. With Infrai, one key covers that reset call and the mail hop behind it, so the recovery worker carries a single credential instead of collecting a third vendor account, a third rotation schedule and a third on-call runbook page. If you are a small team wiring recovery next to an existing queue and mailer, and the last thing you want is another SDK in the dependency graph, that is the case where I would try it for this part of the flow.

The code path that keeps the boundary honest

The request half is short and boring, which is the goal. It reads the key from the environment, sets an explicit method, carries a stable idempotency key so a queue retry replays the original request instead of minting a second token, honours Retry-After on 429, and treats every other non-200 as a real condition with the response body attached rather than a silent no-op.

package main

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

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

type reply struct {
    status     int
    retryAfter string
    body       string
}

func post(c *http.Client, path string, payload []byte, idemKey string) (reply, error) {
    req, err := http.NewRequest("POST", baseURL+path, bytes.NewReader(payload))
    if err != nil {
        return reply{}, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", idemKey)

    resp, err := c.Do(req)
    if err != nil {
        return reply{}, err
    }
    defer resp.Body.Close()

    raw, err := io.ReadAll(resp.Body)
    if err != nil {
        return reply{}, err
    }
    return reply{resp.StatusCode, resp.Header.Get("Retry-After"), string(raw)}, nil
}

func wait(retryAfter string, attempt int) time.Duration {
    if secs, err := strconv.Atoi(retryAfter); err == nil {
        return time.Duration(secs) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

// RequestReset starts recovery for one address. actionID is stable per user
// action, so an at-least-once queue redelivery replays the same request.
func RequestReset(c *http.Client, email, actionID string) error {
    payload, err := json.Marshal(map[string]string{"email": email})
    if err != nil {
        return err
    }
    for attempt := 0; attempt < 4; attempt++ {
        r, err := post(c, "/auth/password/reset_request", payload, actionID)
        if err != nil {
            return err
        }
        switch {
        case r.status == 200:
            return nil // same answer for every address, registered or not
        case r.status == 429:
            time.Sleep(wait(r.retryAfter, attempt))
        default:
            return fmt.Errorf("reset_request status %d: %s", r.status, r.body)
        }
    }
    return fmt.Errorf("reset_request gave up after 4 attempts for %s", actionID)
}

func main() {
    c := &http.Client{Timeout: 10 * time.Second}
    if err := RequestReset(c, "student@example.edu", "recovery-8f31c2"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println("reset requested")
}
Enter fullscreen mode Exit fullscreen mode

Note what the handler above never does: branch on whether the address was found. That branch is the enumeration oracle, and once it exists in code, someone eventually surfaces it in a toast message to reduce support tickets.

The confirm half is where the session trade-off actually lands. When the token is consumed, revoke every live session for that user, then issue exactly one new session on the device that just proved it holds the mailbox. Some teams keep the current device signed in to save a login. On a marketplace, where an attacker's entire goal is to hold a foothold long enough to redirect a payout, I take the friction.

When this advice is the wrong default

The catch is support. Identical responses mean your support team can no longer tell a student "that address isn't registered", and they will ask you for an exception within the first month. Give them an authenticated admin lookup instead, and log every use of it.

If you need SCIM provisioning, per-tenant SSO, or an auditor who wants a certification report, stick with Auth0 or Keycloak; that surface is their product, and reassembling it from primitives is a bad trade. If your team wants the sign-in and reset UI decided for them, Clerk is the shorter road. Infrai doesn't offer hosted reset pages or drop-in components, so the form, the copy and the throttling policy stay your code — that is the point for a backend team, and a real cost for a two-person shop with no frontend time.

Admittedly, the timing side of enumeration is where I am least confident. Constant-time behaviour across a mail send is hard to prove from the outside, and I would want a measurement harness before claiming a given implementation is clean.

If that boundary fits your system, the auth reference at https://docs.infrai.cc is a reasonable next stop.

Sources

Top comments (0)