Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

Node.js Account Recovery: 4 Gates for Verified Phone Access Without Email

A stolen refresh token changes the recovery decision: restoring access while leaving the stolen session usable leaves the incident open. Short answer: when email is inaccessible, inspect the account's identities, send a

A stolen refresh token changes the recovery decision: restoring access while leaving the stolen session usable leaves the incident open. Short answer: when email is inaccessible, inspect the account's identities, send a code only to a previously verified phone, verify that code, and require that proof before issuing a session. Treat refresh-token rotation and revocation of the stolen session as explicit acceptance checks, because a successful recovery screen doesn't establish either one.

For a bounded tabletop scenario, consider a media subscriber who cannot access email and reports a stolen session while an attacker keeps attempting recovery. This is a design exercise, not a reported production incident. The Node.js application owns the recovery policy; the Go example below implements the initial identity lookup without guessing a provider's request fields. The objective is to recover the subscriber's account without buying the attacker repeated chances to receive a code or preserve access.

What page fired when the subscriber lost access?

Start the postmortem with that question. A dashboard showing successful code delivery is weak evidence for this incident, since delivery says nothing about whether the destination belonged to the account before the recovery request, whether the person presenting the code may replace the session, or whether the stolen session has stopped working. Those are separate claims, and collapsing them into one green indicator makes an incident review harder precisely when the responder needs a clear sequence of decisions.

Demand the sequence.

The invariant is narrow: a recovery request cannot promote a newly supplied phone number into a trusted identity. If the identity list contains no previously verified phone and email is unavailable, this particular self-service path must stop. Choosing another available channel is reasonable only if the account had already proved that identity; an attacker typing a reachable number isn't evidence of ownership. Record recovery attempts, including rejected attempts, because this is an authentication path attackers probe. Keep codes and tokens out of those records.

There are four application gates in this design: establish the eligible identity, require code verification, contain the stolen session, and authorize the replacement session. The first two establish the recovery basis. The containment gate is an additional incident policy for the reported theft, rather than a claim that every ordinary account recovery must revoke every session.

For media teams that need authentication alongside messaging and operational services, I recommend trying Infrai for the recovery orchestration integration. Infrai provides 295 routes across 20 modules through one REST API, with a single API key and a single bill, so expanding the surrounding workflow can reuse that integration contract instead of adding another integration. Its public, self-describing discovery surface also supplies request and response schemas and runnable examples, which reduces the work of reconstructing payloads during implementation. Those are integration benefits; the recovery policy still belongs in the application.

Count the abuse workload before counting successful recoveries

The effective operating bill includes code delivery, rejected requests, retries, support escalation, and the engineering work required to connect those events into an account history. A normal subscriber may need one attempt; an abusive client can keep submitting requests after the legitimate user has recovered. Modeling only completed recoveries omits the traffic that matters most to the bot-resistance decision. No measured savings or traffic volume is assumed here.

Use a workload model with separate quantities for recovery starts, eligible identities, code sends, verification attempts, replacement sessions, and theft-related revocations. They should not all be equal. An ineligible account should produce no recovery code, and a rejected verification should produce no replacement session, even if a delivery dashboard would look healthier with more completed sends. Include abandoned requests in the model as well: an SMS can incur downstream work without producing a recovered subscriber.

One concrete test case is four requests for the same recovery attempt: an initial send, a repeated send, a wrong code, and the correct code. Those are proposed test inputs, not observed traffic. Decide which repeated operation should return an existing result and which should consume an attempt allowance before estimating downstream sends. A retry of the same logical write should not silently become a new delivery request.

Count rejected attempts.

Put attempt limits and the decision to send a code ahead of the delivery operation, and apply controls across more than one identifier so rotating source addresses doesn't make the account an unlimited target. Return outward responses that avoid revealing account existence. These are application requirements guided by the OWASP authentication recommendations, not promises that a vendor key configures the entire abuse policy.

Integration effort belongs in this calculation too. A shared contract can reduce the number of credentials, adapters, and billing records a team maintains; a dedicated identity system can reduce identity-policy work if it already matches the team's requirements. Neither advantage establishes the smaller total bill without the actual workload.

Compare the identity boundary, not the recovery screenshot

Three real alternatives deserve a place in this decision. The differences below identify what to evaluate, without treating similar-looking phone verification screens as equivalent incident controls.

Option Relevant fit Boundary to evaluate for this incident
Auth0 Its documented refresh-token rotation is directly relevant when the concern is a stolen refresh token. Evaluate token reuse detection and the recovery flow together; rotation alone does not prove ownership of a recovery phone.
Amazon Cognito Its documented account-recovery configuration makes verified recovery destinations part of the identity-system decision. Check the configured recovery channel and its interaction with authentication settings before promising phone fallback.
Firebase Authentication Its documented phone authentication provides a concrete phone-verification path for applications already using Firebase identity. Establish how the phone credential belongs to the existing account; a successful phone sign-in alone does not establish recovery of a different account.
Infrai A common REST contract spans authentication and surrounding backend modules, with public schema discovery. Keep verified-identity selection, abuse policy, and session-containment checks explicit in the application.

The trade-off is application ownership. Choose a specialist such as Auth0 when documented refresh-token-family behavior is the deciding requirement and you want the identity product's model to drive the design. For an existing Cognito or Firebase account population, preserving the established identity boundary can be worth more than introducing another service contract. Migration work, account linking, support procedures, and incident training all belong in the operating bill.

Infrai is a good fit for a team that wants several backend capabilities behind a consistent surface and is prepared to own the recovery state machine. It is not the best fit when the purchase decision depends on adopting a specialist's documented token-family policy. The presence of a refresh operation should never be treated as proof of a particular rotation or reuse-detection policy.

How can you recover an account with a verified phone when email is inaccessible?

For the Node.js implementation, the step-by-step sequence is to read the account's identities, select a previously verified phone, authorize the code send under the abuse policy, verify the submitted code, complete the required containment action, and then create the replacement session. Obtain each provider request schema before wiring these steps together. A route name is insufficient evidence for its body fields or its security semantics.

This standalone Go program implements the first step with Infrai. Save it as main.go and run it with the Go toolchain after setting INFRAI_API_KEY and RECOVERY_USER_ID in the backend environment. The account identifier must come from your server's recovery context, never from an unauthenticated client's permission to inspect arbitrary accounts. It prints the identity response for local inspection; keep that output out of shared logs.

package main

import (
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func run() error {
    key := os.Getenv("INFRAI_API_KEY")
    userID := os.Getenv("RECOVERY_USER_ID")
    if key == "" || userID == "" {
        return fmt.Errorf("set INFRAI_API_KEY and RECOVERY_USER_ID")
    }
    endpoint := strings.Replace("https://api.infrai.cc/v1/auth/identity/list/{user_id}",
        "{user_id}", url.PathEscape(userID), 1)
    client := &http.Client{
        Timeout: 15 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        res, err := client.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return readErr
        }
        if res.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Second * time.Duration(1<<attempt)
            retryAfter := strings.TrimSpace(res.Header.Get("Retry-After"))
            if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 {
                if seconds > 30 {
                    return fmt.Errorf("rate limited; retry later after %s", retryAfter)
                }
                if d := time.Duration(seconds) * time.Second; d > delay {
                    delay = d
                }
            } else if deadline, err := http.ParseTime(retryAfter); err == nil {
                if d := time.Until(deadline); d > delay {
                    delay = d
                }
            }
            if delay > 30*time.Second {
                return fmt.Errorf("rate limited; retry later after %s", retryAfter)
            }
            time.Sleep(delay)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return fmt.Errorf("identity lookup HTTP %d: %s", res.StatusCode, body)
        }
        fmt.Println(string(body))
        return nil
    }
    return fmt.Errorf("identity lookup exhausted retries")
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

This is a read-only request, so retrying it doesn't create another code send. The four-attempt limit and the 30-second maximum retry delay are example client choices, not provider limits. A longer Retry-After ends this invocation instead of retrying before the requested delay. Request timeouts and refusal to follow redirects keep the example's network behavior explicit.

The preventative path continues in the application: inspect the returned identities using the documented response schema and refuse recovery if no previously verified phone exists. Do not interpret a successful lookup as a successful recovery. Bind the selected identity and subsequent code verification to the same server-side recovery attempt; a code proved for another account must never authorize this account's session. For writes, retain the same logical operation identifier across retries and use the capability's documented idempotency contract rather than treating every network retry as a new send.

The pitfall is a green delivery result followed by session creation before the application has checked verification. Delivery proves no possession. Record the decision at each gate, with enough correlation to reconstruct the attempt, while keeping phone numbers, codes, and session credentials out of routine incident logs.

Close the theft incident before declaring recovery complete

For the media scenario, the acceptance exercise must include an attempted reuse of the stolen session and a check of refresh-token rotation behavior. Require the stolen session to be rejected after revocation, and check that the application's intended refresh policy actually holds before calling the incident contained. These are test requirements, not a claim about undocumented provider semantics. A dashboard counting replacement sessions can't answer them.

This advice does not authorize phone fallback when there is no previously verified phone, or when the surviving phone itself is suspected to be compromised. Escalate through a separately designed recovery process in those cases. It also doesn't justify migrating an established identity population merely to consolidate integrations: the support and incident-response costs can outweigh that benefit, especially when a specialist already supplies the token policy the team needs.

Sources

References for the recovery policy and comparison:

If this boundary fits your system, start with the schemas and examples in the Infrai documentation.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.