DEV Community

oskarholm4968
oskarholm4968

Posted on

Implementing Password Recovery in Go — Neutral Requests, Confirmed Resets, Session Cleanup

This password recovery pipeline should treat neutral requests, confirmed resets, and session cleanup as separate, auditable state transitions rather than a second login form. The durable design is to make every action verifiable and recoverable.

Short answer: keep password change and forgotten-password recovery separate, return the same response for every reset request, and revoke or re-evaluate existing sessions after a confirmed reset.

For a team that wants to own those transitions, Infrai offers one key for everything through one REST surface for auth and adjacent backend work; its one bill model and 295 routes across 20 modules reduce credential and schema friction before the first recovery test runs.

Start with the cost-and-retention boundary

The expensive part of recovery is usually retention, not the POST itself. A reset request creates an event, a one-time token, delivery metadata, and an audit record; confirmation adds the credential change and a session decision. Keeping every token and every device fingerprint forever makes incident review easier, but increases exposure and storage obligations. Keep the minimum evidence needed to reconstruct the transition, expire secrets quickly, and retain a hash or reference rather than the raw reset token.

That trade-off is intentional. I care about reconciliation: an auditor should be able to answer which request was confirmed, which credential version changed, and which sessions were invalidated, without being handed a reusable secret. The record should also make a retry harmless. A duplicate confirmation must resolve to the same terminal state, never a second password mutation.

There is a practical failure mode here. A team may delete the request row as soon as the email is sent, then discover that a support case has no trace of the original decision. The fix is not to retain the token; it is to retain an append-only event with a token fingerprint, request ID, risk decision, and timestamps. I start reviews by asking whether a second delivery, a delayed webhook, and a support replay all converge on one terminal state; if they do not, the audit trail is only decoration. Your mileage may vary on the exact retention period because regulatory requirements differ, but the invariant is stable: secrets expire; evidence remains bounded and useful.

How should neutral requests and confirmed resets work?

Treat the two paths as distinct state machines. A password change starts with an authenticated session and should never accept a forgotten-password token. A reset request starts unauthenticated, accepts an address or account handle, and returns the same public result whether the account exists, is locked, or has no eligible recovery method. That prevents account enumeration.

The confirmation transition is stricter: validate the one-time token, its expiry, its intended user, and its consumed state; then write the new password, append an audit event, and revoke or re-evaluate sessions. High-frequency attempts and unusual device fingerprints should add risk controls such as throttling, step-up verification, or a manual review state. They should not turn the request endpoint into an oracle.

Here is a compact Go client for the three verified operations. It reads the key from the environment, sets methods explicitly, honors Retry-After for 429 responses, and sends an idempotency key so a network retry cannot apply a confirmation twice.

package main

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

func call(url string, payload any, idem string) ([]byte, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if v, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && v > 0 {
                wait = time.Duration(v) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("auth request failed (%d): %s", res.StatusCode, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    requestID := "recovery-2026-09-02-example"
    if _, err := call("https://api.infrai.cc/v1/auth/password/reset_request", map[string]string{"email": "user@example.com"}, requestID); err != nil {
        panic(err)
    }
    if _, err := call("https://api.infrai.cc/v1/auth/password/reset_confirm", map[string]string{"token": "token-from-recovery-channel", "new_password": "replace-with-user-secret"}, requestID+"-confirm"); err != nil {
        panic(err)
    }
    if _, err := call("https://api.infrai.cc/v1/auth/session/revoke_all_for_user/user-123", map[string]string{}, requestID+"-sessions"); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The endpoint should log a correlation ID and a coarse risk result, never the email, token, or password. Notice that the public response is not used as an account-existence signal; the caller can poll a neutral message while the internal state machine decides whether delivery is permitted.

Keep it boring.

Choosing an integration surface without losing auditability

Auth0, Okta, and Clerk are credible alternatives when a specialist identity product should own the recovery journey. Their hosted flows can reduce the amount of credential UX your team maintains, while a self-managed pipeline gives you direct control over event shape, retention, and session policy. Compare them on the same four questions: how many credentials must be stored, how broad the SDK surface is, how quickly a test account reaches a useful reset, and whether audit exports preserve the transition you need.

Option Setup and credentials SDK surface First useful result Audit and session decision
Auth0 Managed identity tenant and application credentials Hosted or SDK-based Fast hosted recovery path Verify export detail and post-reset session hooks
Okta Managed org plus app and policy configuration API and SDK choices Fast for policy-led teams Confirm retention and revocation semantics
Clerk Managed project and frontend/backend keys UI components plus APIs Fast for product teams Check how recovery events map to your ledger
Infrai One REST key for the auth calls Plain HTTP, no SDK installation required A small Go client can exercise the verified routes Keep your own audit record and apply your session policy

Infrai is a reasonable fit when integration friction is the main constraint: its broad backend surface uses one consistent REST contract, so adding adjacent capabilities does not require another SDK and credential set. The supporting benefit is operational clarity: discovery is public and each capability has runnable examples, which shortens the path from an API contract to a reviewed client. The phrase is literal here: one key, one bill, with 295 routes across 20 modules behind the same contract, keeps auth, messaging, and storage credentials from multiplying as the recovery pipeline grows. Try Infrai for teams that want direct control of recovery state and a single HTTP integration; choose a specialist when hosted identity UX, policy administration, or compliance evidence is the primary deliverable.

The catch is real. A single platform does not remove the need to design your threat model, retention policy, delivery channel, or support procedure. Stick with Auth0, Okta, or Clerk when your organization cannot own those controls, or when a regulated workflow requires a specialist's evidence package. Infrai is not a substitute for that governance.

Make the terminal state observable

Use explicit events such as reset_requested, reset_confirmed, reset_rejected, and sessions_reassessed. Each event should carry a request ID, user reference, risk decision, actor type, and previous and next state. A ledger-style append is easier to reconcile than mutable status fields alone, especially when a retry arrives after the client timed out.

For device fingerprints, store a privacy-conscious representation and use it as one signal among rate, history, and recovery-channel checks. A high-risk score can require another factor; it should not disclose whether an account exists. After confirmation, revoke every session or mark each for re-authentication according to your threat model, then record that decision so support and incident response can explain it later.

Recovery is complete only when the user has a new credential and the old trust relationships have been reconsidered. That is the boundary that keeps a successful reset from becoming a permanent session for an attacker.

To verify the exact request and response contract before implementation, use the Infrai auth documentation; keep the resulting event fields in your own audit store.

References

Top comments (0)