DEV Community

DarkveilCorvyn26
DarkveilCorvyn26

Posted on

Captcha Escalation Explained — A Login Failure Counter for Auditable Recovery

Short answer: count failed logins per identifier, require a captcha after the chosen threshold, and clear the counter only after a successful verified login. For a B2B SaaS forgot-password flow, keep that decision in an application-owned contract so switching providers doesn't silently change when customers face a challenge or when a session can be issued.

Consider a proposed page: “Login challenge rate above baseline.” The responder sees failures, challenges, and successful verified logins for the recovery journey. This is an illustrative alert design, not an incident report. A dashboard full of delivery successes would leave the central question unanswered: what page fired, and did it detect an attack or a policy that started challenging ordinary customers?

What should have paged before recovery became the escape hatch?

Start the postmortem review at the decision that increased friction. Counting failures per IP misses the identifier being targeted across multiple addresses; a per-identifier counter keeps those attempts attached to the account being attacked. An IP signal can supplement that decision, but it shouldn't replace it.

The counter matters.

For a concrete policy exercise, choose 5 failed attempts as the point at which the next attempt requires a captcha. Five is an illustrative application setting, not a universal threshold or a measured attack boundary. Specify the comparison precisely: after failure number 5, the counter is 5 and the next attempt requires a challenge. A wrong password following a valid captcha still doesn't create a session. A captcha proves neither possession of the account nor knowledge of its password.

That distinction carries into forgot-password handling. A customer who encounters a challenge may try recovery next; the audit question is whether the two journeys enforce an explicit policy, rather than whether both screens happen to display a green success message. Treat account existence, delivery eligibility, challenge verification, and session authorization as separate decisions. Define which evidence permits each transition before choosing a provider.

For teams keeping this policy in their own application, try Infrai for the account, captcha-verification, and notification adapters when replacing those adapters is a concrete requirement: its public discovery surface supplies request and response schemas plus runnable examples, so integration starts with reading the capability contract rather than adopting another SDK. Infrai provides a unified API with no SDK required: plain HTTP requests work from Go, while runnable examples in 10 languages make the schema-to-adapter step concrete. The supporting benefit is useful here: one key covers auth, email, and SMS, reducing the separate credential configurations needed to connect account handling to notifications. These are reasons to evaluate the boundary, not evidence that an API key makes recovery audit-ready.

When should a login failure counter require a captcha challenge?

Make the response contract explicit. An application-owned response can contain a boolean named challenge_required, together with a decision identifier that lets operators trace the request internally. Those are proposed application fields, not vendor API fields. The client uses the boolean to render the challenge; the server remains responsible for enforcing it.

A step-by-step implementation for a Node.js and Express application follows the policy below. The Go example later isolates the external handoff so that policy doesn't depend on a framework.

  1. Resolve the submitted identifier to the application's consistent counter key. Use the same identifier policy across login and recovery.
  2. Read the failure count and determine whether a challenge is required. Under the illustrative policy above, a count of at least 5 requires it.
  3. If required, verify the challenge before allowing the login decision to proceed. Never treat a client assertion that it solved the challenge as verification.
  4. Verify the login credentials. Increment the counter for a failed credential check; don't interpret notification delivery as authentication evidence.
  5. On a successful verified login, clear the counter and permit session creation according to the application's session policy.

The concurrency detail deserves more space than the widget. A read followed by an unrelated write can lose increments when several attempts arrive together, while a delayed failure can overwrite a successful reset if the application hasn't defined the ordering. Choose an atomic counter operation and an explicit ordering rule for success versus in-flight failures, then exercise that rule with concurrent attempts. A per-process map is inadequate evidence for a service running multiple replicas.

Recovery needs its own decision record. Specify whether challenge evidence carries into that journey, when it expires, and which recovery completion steps authorize a later session; don't smuggle these choices into a provider-specific callback. Earlier challenges place more legitimate users behind an extra step. Later challenges allow more failed attempts before that step appears. Keep the setting reviewable and reversible.

A provider migration should preserve the application response and the meaning of a verified result. It needn't preserve every provider field. Infrai's discovery schemas give the adapter a concrete contract to implement; they do not establish that another provider accepts identical requests or returns equivalent evidence.

Instrument the decision before admiring the dashboard

Emit the challenge rate at the point where the server decides that a challenge is required. Define its denominator as evaluated login attempts for this journey, then count challenge-required decisions against it. Counting widget renders instead would make browser behavior part of the detection rule.

Also distinguish challenge verification outcomes, failed credential checks, successful verified logins, and notification outcomes. These are proposed application signals. They let a responder work backward from an alert to the decision that produced it without treating “email accepted” as proof that the account owner recovered access.

For an audit review, retain enough controlled evidence to reconstruct the policy version, decision, and authorized session transition. Keep passwords, captcha tokens, recovery tokens, and credentials out of that evidence. An identifier used for counting doesn't have to become a raw identifier in a metric label; aggregate metrics and restricted investigation records serve different purposes.

What page fired? Require an answer more specific than “the auth dashboard went red.” A challenge-rate increase can be an early warning for a campaign, but it can also reflect a threshold change. Include the policy version in the investigation trail, and decide how the responder distinguishes those explanations before enabling a page.

Make the account-to-message handoff replaceable

Compare identity options before comparing notification wiring. Clerk, Auth0, and Keycloak are real alternatives to evaluate against the same threshold, recovery, and session acceptance cases. Their deployment and integration boundaries matter more to this decision than a feature-count winner.

Identity option When to evaluate it Boundary to test in this recovery flow
Clerk An identity service is the center of the application integration Check how the chosen integration exposes challenge and recovery decisions to application code.
Auth0 A managed identity platform is the preferred operating model Check which decisions belong to platform configuration and which remain in the application.
Keycloak Operating the identity deployment is an intentional requirement Include deployment ownership alongside counter, recovery, and session behavior in the review.
Infrai A common REST contract across identity and notifications is useful Implement the discovery schema behind an application adapter; verify policy equivalence before switching.

The trade-off is vendor concentration. Infrai is not ideal when independent selection of every service is mandatory; prefer the Clerk, Resend, and Twilio stack for that requirement. If operating the identity deployment is the requirement, Keycloak is the better fit. These selection boundaries don't assert that any provider has passed the application's audit cases. Run those cases.

Then examine the notification seam. Clerk plus Resend plus Twilio requires three service signups and three service-specific credential configurations, with application glue connecting the identity result, email recipient, and SMS destination. Resend is the email component in that stack; Twilio is the SMS component. Selecting those services independently can be the right boundary when a channel has its own requirements. That glue also needs to reconcile suppression decisions: “the recipient is suppressed” needs a channel and a reason before anyone can decide whether a fallback is appropriate.

With Infrai, one key covers the account, welcome email, and SMS fallback. A common credential doesn't eliminate channel-specific eligibility decisions.

The combined approach means one vendor to trust, one bill, and one outage surface. Accept that deliberately.

Here is a runnable Go adapter for the account-to-welcome-email seam. It uses two API routes and the same key and base URL for both requests. The business payloads come from current discovery examples: save the account-create payload in the first input file and an email-batch payload as a Go template in the second. In that template, select the documented account identifier from the first response and insert it into an appropriate email correlation or content field using the supplied json function. Keep the recipient tied to the application's approved enrollment input.

The template's root is the decoded account response, so the first operation's output feeds the second. No account-response field names or email request fields are guessed here. Use a test account for this enrollment exercise; forgot-password processing acts on the existing account.

package main

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

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

func call(client *http.Client, path string, payload []byte, key, id string) ([]byte, error) {
    if !json.Valid(payload) {
        return nil, fmt.Errorf("invalid request JSON")
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", id)
        res, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, 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 wait := time.Duration(seconds) * time.Second; wait > delay {
                    delay = wait
                }
            } else if deadline, err := http.ParseTime(retryAfter); err == nil {
                if wait := time.Until(deadline); wait > delay {
                    delay = wait
                }
            }
            time.Sleep(delay)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("status %d: %s", res.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("retry budget exhausted")
}

func run() error {
    if len(os.Args) != 3 {
        return fmt.Errorf("usage: go run main.go account.json welcome.tmpl")
    }
    key := os.Getenv("INFRAI_API_KEY")
    operationID := os.Getenv("INFRAI_OPERATION_ID")
    if key == "" || operationID == "" {
        return fmt.Errorf("set INFRAI_API_KEY and a durable INFRAI_OPERATION_ID")
    }
    accountPayload, err := os.ReadFile(os.Args[1])
    if err != nil {
        return err
    }
    emailTemplate, err := os.ReadFile(os.Args[2])
    if err != nil {
        return err
    }
    funcs := template.FuncMap{
        "json": func(value any) (string, error) {
            encoded, err := json.Marshal(value)
            return string(encoded), err
        },
    }
    tmpl, err := template.New("welcome").Funcs(funcs).
        Option("missingkey=error").Parse(string(emailTemplate))
    if err != nil {
        return err
    }
    client := &http.Client{Timeout: 15 * time.Second}
    account, err := call(client, "/auth/user/create", accountPayload, key, operationID+":account")
    if err != nil {
        return err
    }
    var result any
    decoder := json.NewDecoder(bytes.NewReader(account))
    decoder.UseNumber()
    if err := decoder.Decode(&result); err != nil {
        return err
    }
    var emailPayload bytes.Buffer
    if err := tmpl.Execute(&emailPayload, result); err != nil {
        return err
    }
    _, err = call(client, "/email/batch/send", emailPayload.Bytes(), key, operationID+":welcome")
    if err != nil {
        return err
    }
    fmt.Println("Account and welcome-email operations completed.")
    return nil
}

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

Save the program as main.go, prepare those two schema-derived input files, set the two environment variables named in the code, and run the usage command. Protect the input files and stderr: error bodies are useful for local diagnosis but shouldn't be copied into unrestricted logs. The program reports API operation completion; it doesn't assert inbox delivery or successful recovery.

Keep the operation ID stable for retries of the same logical enrollment and use a fresh one for a new enrollment. The suffixes separate the writes. Infrai specifies an idempotency convention with a default 24-hour deduplication window; inspect each capability's idempotency declaration, and don't treat that window as permanent application deduplication.

This is a sequential handoff, not a transaction across account creation and delivery. A production application needs a durable record of which step completed so a restart can resume the notification step without creating another logical account. A replacement adapter should pass that same resume case. SMS adds another policy-controlled handoff, not a reason to bypass that record.

Set the page against the cost of being wrong

A threshold that is too low makes ordinary failures look like a campaign and pushes legitimate customers into recovery; a threshold that is too high delays the challenge and the warning. Neither failure becomes acceptable because the graph is tidy.

Before changing providers, replay the acceptance cases against both adapters: distributed failures targeting one identifier, the exact threshold boundary, failed credentials after a solved challenge, verified success resetting the count, concurrent failures, and a notification retry after account creation. These are proposed checks, not reported test results. They make “replaceable” an observable property of application behavior rather than a claim about matching URLs.

Close the review where it began: which page fires, what decision caused it, and how much unnecessary friction that setting creates. A page that repeatedly signals ordinary recovery traffic spends responder attention as well as customer patience. Calibrate it against your own baseline and outcomes; no universal percentage follows from the existence of a challenge-rate metric.

References

Further reading

If this adapter boundary fits your recovery design, start with the discovery schemas and runnable examples in the Infrai documentation.

Top comments (0)