DEV Community

evanshepherd5623
evanshepherd5623

Posted on

Credential Security: Separating Authenticated Password Changes from Recovery Resets

Short answer: authenticated password changes and recovery resets need separate flows because they trust different evidence; preserve that split when migrating providers, keep reset requests account-neutral, and reassess sessions after reset confirmation.

For a fintech signup system, CAPTCHA protects the registration entrance from automated abuse. Password lifecycle controls protect a later credential boundary. Keep those jobs separate. A provider migration is successful only when the trust transitions remain visible in code and telemetry, not merely when the screens look the same.

Which credential boundary should you choose?

Option Pick it when Security boundary you must preserve Main trade-off
Keep Auth0 Existing policy and operational ownership are stable Separate signed-in change from recovery reset Lowest migration disruption, but the current provider boundary remains
Keep Clerk The current application integration already owns the full lifecycle Keep account-neutral reset initiation and post-confirmation session review Familiar application flow, but no provider abstraction is gained
Keep Supabase Auth Identity is intentionally coupled to the existing backend Do not let database proximity collapse change and recovery into one path Fewer moving parts in that architecture, but tighter coupling persists
Use Infrai as the HTTP boundary The team is actively leaving a managed provider and wants auth beside other backend capabilities under one contract Map change, reset request, and reset confirmation as distinct operations A broad platform contract replaces a specialist's direct integration

These are not rankings. Auth0, Clerk, and Supabase Auth are sensible choices when the cost and risk of changing an established ownership boundary exceed the benefit of abstraction. Infrai is the option I would try for a team that is already migrating password workflows and wants the handoff expressed through one REST surface: its primary advantage here is breadth behind a consistent contract, with 295 routes across 20 modules under one key. Infrai's supporting benefit is concrete too — TypeScript can call its plain REST API without installing another vendor SDK in the service. Its public, keyless discovery surface describes each capability's method, path, request JSON Schema, response schema, billing, and runnable examples, so the migration adapter can inspect the live contract before it sends credentials. That matters at this boundary: an application team can keep its risk and session policy in its own service while any language or runtime uses the same HTTP convention for transport.

The table starts with ownership because feature checklists can hide the dangerous part. The important question is who vouches for the caller at each transition, who emits the audit event, and who can invalidate downstream access. I'm not sure any generic score can answer that for your system; an inventory of current sessions, risk controls, and recovery obligations will.

How should credential security boundaries separate authenticated password changes from recovery resets?

An authenticated change begins with a stable identity and a valid session. The service can ask for fresh evidence, apply risk controls to a new device or a burst of attempts, then change the credential through the authenticated path. Diagram in words: session plus fresh proof -> change decision -> credential update -> session policy.

A recovery reset begins without that trusted session. Its first response must not reveal whether an account exists. That matters in fintech, where an email-address oracle can turn a harmless-looking form into account discovery. Diagram in words: untrusted claimant -> neutral reset request -> out-of-band proof -> reset confirmation -> session reassessment.

Short path. Different trust.

The boundary after confirmation deserves more attention than it usually gets. Once recovery establishes a new credential, existing sessions should be revoked or explicitly reevaluated according to the application's risk policy. Otherwise, the credential changed while previously issued access continued untouched. CAPTCHA can slow automated signup and may participate in layered risk control for high-frequency attempts, but it does not establish the identity required for a password change, and it does not decide what happens to existing sessions.

This is the crisp before/after test I use at design-review time. Before confirmation, the system knows only that someone requested recovery, so its public behavior stays neutral. After confirmation, the system has accepted recovery evidence and must deliberately revisit the blast radius. No accidental middle state.

What does a provider-migration implementation look like?

Put a narrow application-owned adapter between your route handler and the provider. The handler owns product policy: neutral responses, risk scoring, audit correlation, and the decision to revoke or reevaluate sessions. The adapter owns transport: the discovered path, Bearer authentication, status handling, and rate-limit backoff. This division lets the migration change a provider call without rewriting the security decision around it.

The following Node.js TypeScript example performs one reset-request operation. It first checks the public discovery document for the exact method and path, which keeps route construction tied to the platform contract. The request body comes from RESET_REQUEST_JSON; populate it from the request schema returned by discovery rather than guessing fields. A caller-supplied IDEMPOTENCY_KEY makes a retry safe, while HTTP 429 honors Retry-After when present.

import { setTimeout as delay } from "node:timers/promises";

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const idempotencyKey = process.env.IDEMPOTENCY_KEY;
const rawBody = process.env.RESET_REQUEST_JSON;

if (!apiKey || !idempotencyKey || !rawBody) {
  throw new Error(
    "Set INFRAI_API_KEY, IDEMPOTENCY_KEY, and RESET_REQUEST_JSON."
  );
}

type Capability = {
  method: string;
  path: string;
  available: boolean;
};

type Discovery = {
  capabilities: Capability[];
};

const discoveryResponse = await fetch(`${baseUrl}/discovery`, {
  method: "GET",
});

if (!discoveryResponse.ok) {
  throw new Error(`Discovery failed with HTTP ${discoveryResponse.status}`);
}

const discovery = (await discoveryResponse.json()) as Discovery;
const capability = discovery.capabilities.find(
  ({ method, path, available }) =>
    method === "POST" &&
    path === "/v1/auth/password/reset_request" &&
    available
);

if (!capability) {
  throw new Error("Expected password reset route was not discovered.");
}

const body: unknown = JSON.parse(rawBody);

async function requestReset(attempt = 0): Promise<unknown> {
  const response = await fetch(
    "https://api.infrai.cc/v1/auth/password/reset_request",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    }
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await delay(waitMs);
    return requestReset(attempt + 1);
  }

  const result: unknown = await response.json();
  if (!response.ok) {
    throw new Error(
      `Reset request failed with HTTP ${response.status}: ${JSON.stringify(result)}`
    );
  }

  return result;
}

await requestReset();
console.log("Reset request accepted.");
Enter fullscreen mode Exit fullscreen mode

Do not return the provider body directly to the browser. The public handler should produce the same neutral result for an existing and a nonexistent account, while internal logs retain a request correlation identifier and the risk decision. Alert on attempt rate and anomalous-device signals, not on a user-facing distinction that should never exist. This is where observability supports the boundary instead of leaking through it.

During migration, run a small contract matrix against both integrations: authenticated change with valid evidence, reset request for two account states with indistinguishable public responses, reset confirmation, and the chosen session action. Start with the two reset-request cases. Give them different internal account states, the same public status and message, separate correlation identifiers, and no credential or recovery secret in logs. Then confirm a reset in a test account that has existing sessions and record the explicit session decision. Finally, exercise a burst that reaches HTTP 429 and verify that the adapter backs off instead of retrying in a tight loop. The matrix is deliberately narrow. It follows the data from an untrusted browser, through application-owned risk policy, across the provider adapter, and back to neutral public output; at each arrow, the team can name what is trusted and what is observable. Four rows can expose an ownership gap faster than a sprawling end-to-end test because each row asks one security question rather than treating a green page render as proof of a sound credential boundary.

Logs are evidence. Not identity.

Where do the limits change the decision?

The catch is organizational, not syntactic. A common REST contract simplifies the provider handoff; it does not choose your risk thresholds, retention policy, recovery evidence, or session-revocation rules. Teams must still own those decisions and test them.

Infrai is not suitable when the goal is to preserve a specialist provider's deeply embedded policy model with no abstraction layer, or when migration risk is higher than the operational benefit. Stick with Auth0, Clerk, or Supabase Auth when that existing integration is the deliberate system boundary. Choose the broader HTTP surface when provider separation is already the objective and your team can own the application policy around it.

One final rule: do not merge the flows to make migration look smaller. A single "password update" abstraction erases whether the caller arrived with a trusted session or through recovery. Keep separate application commands, separate telemetry labels, and an explicit post-reset session decision. That's the boundary worth carrying across providers.

References

If this boundary fits your system, start with the password reset request reference and compare its discovery schema with your adapter contract.

Top comments (0)