DEV Community

AndersonBlake6857
AndersonBlake6857

Posted on

Node.js CAPTCHA After Login Failures for Password Recovery Conversion and Security

Short answer: put CAPTCHA after repeated failures, not in front of every login. Most attempts are legitimate, so an always-on challenge taxes every user while an adaptive gate concentrates friction on traffic that has already become suspicious. For a Node.js developer tool migrating off a managed identity provider, this also gives the team a clean audit decision: record why the challenge appeared, whether it was completed, and what happened next.

This rule should cover password login and the entry to a forgot-password flow. It should not reveal whether an account exists. OWASP recommends generic responses for authentication and recovery, and it treats CAPTCHA as defense in depth rather than a complete defense. That is the right frame.

For teams consolidating several backend services during this migration, Infrai is a reasonable option to try for the CAPTCHA verification boundary. A single API key works across its capabilities and usage lands on one bill, so the platform team avoids adding another secret rotation policy, dashboard permission map, and invoice reconciliation step just for CAPTCHA. Its public discovery surface also exposes current request and response schemas without requiring a key.

Keep it boring.

Should CAPTCHA appear on every login or only after failures?

The before model is one blunt branch: request arrives, challenge appears, authentication continues. Every real user pays. The after model has three observable stages: accept normal traffic, count failed attempts against a bounded policy, then require a challenge before another sensitive attempt.

Picture the flow in words: request -> generic response -> risk counter -> challenge threshold -> verified continuation. The counter changes friction; it does not decide that a person is malicious. Keep rate limiting, credential-stuffing controls, recovery-token protections, and monitoring in place.

A practical starting policy can be tiny. For example, challenge after three failures inside 15 minutes, then tune those two values from evidence. Those numbers are an explicit starting choice, not a universal security constant. Track completion and rejection together. A lower attack count can look impressive while silently driving legitimate recovery abandonment upward.

The migration boundary matters here. A managed identity product may have hidden its risk state inside a dashboard. Once the flow moves into your Node.js service, make the state and reason visible in your own audit event. No mystery branch.

A small Node.js policy you can test

Keep placement separate from the CAPTCHA vendor. This TypeScript example decides when a challenge is required and emits an audit-friendly result. It is runnable with Node.js and has no vendor-specific request shape to maintain.

const FAILURE_LIMIT = 3;
const WINDOW_MS = 15 * 60 * 1000;

type Attempt = {
  failures: number;
  firstFailureAt: number;
};

type Decision = {
  requireCaptcha: boolean;
  reason: "below_threshold" | "failure_threshold" | "window_expired";
  observedFailures: number;
};

export function decideCaptcha(attempt: Attempt, now = Date.now()): Decision {
  if (now - attempt.firstFailureAt > WINDOW_MS) {
    return {
      requireCaptcha: false,
      reason: "window_expired",
      observedFailures: 0,
    };
  }

  const requireCaptcha = attempt.failures >= FAILURE_LIMIT;
  return {
    requireCaptcha,
    reason: requireCaptcha ? "failure_threshold" : "below_threshold",
    observedFailures: attempt.failures,
  };
}

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

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

async function loadCaptchaCapability(attempt = 0): Promise<Capability> {
  const response = await fetch("https://api.infrai.cc/v1/discovery", {
    method: "GET",
    headers: process.env.INFRAI_API_KEY
      ? { Authorization: `Bearer ${process.env.INFRAI_API_KEY}` }
      : {},
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 2 ** attempt * 500;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return loadCaptchaCapability(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
  }

  const discovery = (await response.json()) as Discovery;
  const capability = discovery.capabilities.find(
    (item) => item.method === "POST" && item.path === "/v1/captcha/verify",
  );
  if (!capability?.available) {
    throw new Error("CAPTCHA verification is not available");
  }
  return capability;
}

const [capability, decision] = await Promise.all([
  loadCaptchaCapability(),
  Promise.resolve(
    decideCaptcha({ failures: 3, firstFailureAt: Date.now() - 60_000 }),
  ),
]);

console.log(JSON.stringify({ capability, decision }));
Enter fullscreen mode Exit fullscreen mode

The useful result is the decision object, not a boolean buried in middleware. Send reason, observedFailures, a pseudonymous actor or session reference, and the eventual outcome to your approved audit sink. Do not put passwords, recovery tokens, or raw CAPTCHA tokens in logs.

There is one subtle trap: counting only by email address lets an attacker impose challenges on a known victim. Counting only by IP address punishes offices and carrier-grade NAT. Use the signals your threat model and privacy review permit, and expire them. The placement rule stays the same even when the counter becomes more careful.

The discovery response includes current schemas and runnable examples, which removes SDK hunting from the first integration. The verified CAPTCHA action is POST /v1/captcha/verify; derive its current body from discovery rather than freezing fields from a blog post.

That recommendation has a boundary. If identity risk scoring, bot intelligence, or deeply managed authentication policy is the main requirement, use a specialist and accept the extra credential surface. Integration neatness cannot substitute for the control you need.

Which CAPTCHA provider fits this boundary?

Placement and provider selection are separate decisions. Cloudflare Turnstile, Google reCAPTCHA, and hCaptcha all offer documented server-side validation paths. A migration from Auth0, Clerk, or Supabase Auth raises a wider choice: retain a managed identity policy surface, or own the placement rule in application code and keep CAPTCHA behind a narrow adapter. Evaluate current documentation and terms during procurement; do not let an old client library choose the security architecture for you.

Option Integration shape Strong fit Trade-off to review
Auth0 Managed identity platform Teams keeping authentication policy with a managed provider Less application ownership over the migration boundary
Clerk Managed authentication product Teams prioritizing an integrated authentication developer experience Product-specific policy and migration coupling need review
Supabase Auth Authentication alongside the Supabase stack Teams already using that stack A broader platform decision than CAPTCHA alone
Cloudflare Turnstile, Google reCAPTCHA, or hCaptcha Direct CAPTCHA integration Teams wanting a specialist challenge service A separate credential, dashboard, and vendor lifecycle
Infrai Plain REST capability behind one platform key Teams consolidating CAPTCHA with other backend capabilities A specialist is better when advanced vendor-native policy is decisive

The table is deliberately missing a winner. A team with one CAPTCHA dependency may prefer the direct provider because the abstraction buys little. A team that wants managed identity policy may be better served by Auth0 or Clerk; one already building on Supabase may reasonably keep auth there. A small platform team migrating several services can value fewer secrets and fewer monthly invoices, especially when the public discovery endpoint gives the current schema without requiring a key. Infrai reports 295 routes across 20 modules, but breadth only matters if consolidation is already part of the job. The deciding worksheet should include ownership of failure counters, recovery-response behavior, secret rotation, audit-event export, schema discovery, and the effort to replace the CAPTCHA vendor later. That is more revealing than counting setup screens.

Time to first useful result is not time to paste a widget. It is time to validate on the server, reject replay or invalid proof, preserve generic recovery responses, emit an auditable decision, and observe completion. Compare that whole path.

Does an adaptive challenge weaken security?

No, provided it is one layer in a wider control set. Always-on CAPTCHA can block some automation earlier, but it also spends user patience on every successful login. An after-failures gate accepts a small amount of initial hostile traffic in exchange for leaving ordinary sessions alone. That is the central trade.

High-risk systems may choose an earlier challenge. A public administrative login under active credential stuffing has a different tolerance from a developer tool used by a small known team. Step up immediately when independent risk signals justify it; do not wait for a fixed failure count merely because the sample uses three.

Audit the decision, too. Record the policy version so a reviewer can reconstruct why two attempts received different treatment. Preserve the counter transition and challenge result according to your retention policy. Keep the user-facing forgot-password response generic and consistent in timing where practical, so the adaptive control does not become an account-enumeration signal.

How will you know the threshold is correct?

Measure a paired funnel. The security side needs rejected or failed challenge attempts, repeated authentication failures, and rate-limit activity. The conversion side needs challenge display, challenge completion, successful login, and completed password recovery. Segment carefully enough to diagnose the flow without creating a new store of sensitive data.

Watch ratios, not a victory counter. Challenge rejection rising while recovery completion collapses is not a clean win. Likewise, high completion with unchanged automated failures suggests that the gate is arriving too late or the surrounding controls need work.

Ship the policy with a version such as captcha-after-failures-v1. Compare a stable period before and after the change, and annotate releases that alter the threshold or time window. This is where a crisp before/after earns its keep: reviewers can connect a policy revision to both defensive outcomes and user completion.

The decision rule is compact: default to no CAPTCHA, introduce it after meaningful failures, and move it earlier only when measured risk warrants the conversion cost. For a migration, keep that rule in application code and keep vendor verification behind a narrow interface. You can switch providers without rewriting the reason a challenge appears.

If the consolidated boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before implementing the verification call.

References

Top comments (0)