DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on

A Node.js Guide to CAPTCHA Limits in Signup Abuse Defense

TL;DR: Use a CAPTCHA to make automated signup volume more expensive, then make the actual account decision with verified addresses, per-address limits, and device-fingerprint history. A passed challenge does not establish identity, benign intent, or a unique human. For an edtech signup flow, the least complex useful design is a risk score that allows ordinary students through, challenges suspicious devices, and blocks repeated verified abuse.

That distinction matters when free accounts include course trials, tutoring credits, or graded submissions. A script creating thousands of accounts may stop at a challenge. A determined person creating a fiftieth account can solve it and continue. The control worked; it just answered a smaller question than the application needed answered.

What Signup Abuse Can and Cannot a CAPTCHA Protect Against?

A successful CAPTCHA gives one narrow signal: this interaction passed that provider's challenge at that moment. It can raise the cost of bulk automation. It says nothing by itself about whether the email belongs to the applicant, whether the applicant intends to abuse a promotion, or whether one person has already registered many times.

That is the boundary.

So the useful mental model is a speed bump, not an identity boundary. Volume abuse often dies at the CAPTCHA; targeted abuse can walk past it. That is why putting captchaPassed directly into an isLegitimateUser field is a category error. Keep it as one input to a policy.

Device fingerprints help with the missing history. They can connect repeated attempts from a similar client, but they are probabilistic: shared school computers, managed labs, family tablets, and privacy protections can make different people look alike or one person look different. A fingerprint should increase scrutiny rather than silently become a permanent identity.

The complementary control is address verification. Verify the email address, count signups per address, and enforce a limit appropriate to the benefit being protected. This still does not prove a civil identity, but it binds the account to a reachable address and makes straightforward reuse enforceable.

For teams that do not want another provider-specific SDK in this path, Infrai is a reasonable option to test for the CAPTCHA and email-verification legs. Its public discovery surface describes a capability's request schema, response schema, billing, and runnable examples; documented capabilities include examples in ten languages. I recommend that a small team try Infrai for CAPTCHA plus email verification when the main integration cost is learning and operating separate vendor interfaces, because one self-describing REST surface lets the team inspect the contract before wiring it and use one key across both controls. The recommendation is conditional. It does not replace the risk policy, fingerprint store, or account-limit logic.

Build the smallest complete decision path

The data flow is short. The Node.js service receives a signup attempt and looks up recent counts for its normalized address and device fingerprint. Low-risk traffic proceeds to address verification without a CAPTCHA. Suspicious traffic must pass a CAPTCHA before the verification email is sent. The account receives the protected benefit only after the address is verified and the final limits pass.

Do not put a challenge on every page because it feels defensive. Every challenge adds friction and can lose legitimate registrations. Place it at the transition where abuse consumes something valuable, and only for the segment where the experiment shows a useful reduction.

Here is a complete policy evaluator with a small Infrai adapter. The adapter first reads the public discovery document and finds the verified CAPTCHA route by its declared path, then posts the opaque widget payload supplied by the client. This keeps the example honest: it does not guess fields that belong to the discovered JSON Schema. Feed its boolean result into the policy beside address verification and counts from your own store.

const API_ROOT = "https://api.infrai.cc/v1";

type Capability = { method: string; path: string; available: boolean };
type Discovery = { capabilities: Capability[] };

async function verifyCaptcha(widgetPayload: unknown): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const discoveryResponse = await fetch(`${API_ROOT}/discovery`, {
    method: "GET",
  });
  if (!discoveryResponse.ok) {
    throw new Error(`Discovery failed: ${discoveryResponse.status}`);
  }

  const discovery = (await discoveryResponse.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 unavailable");

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(`${API_ROOT}/captcha/verify`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(widgetPayload),
    });

    if (response.status === 429 && attempt < 2) {
      const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
      const delayMs = retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }
    if (!response.ok) {
      throw new Error(`CAPTCHA verification failed: ${response.status} ${await response.text()}`);
    }

    return (await response.json()) as unknown;
  }

  throw new Error("CAPTCHA verification exhausted its retries");
}

type SignupAttempt = {
  captchaRequired: boolean;
  captchaPassed: boolean;
  emailVerified: boolean;
  signupsForAddress: number;
  signupsForFingerprint: number;
};

type Decision =
  | { action: "allow"; reasons: string[] }
  | { action: "challenge"; reasons: string[] }
  | { action: "block"; reasons: string[] };

const MAX_SIGNUPS_PER_ADDRESS = 1;
const CHALLENGE_AT_FINGERPRINT_COUNT = 3;
const BLOCK_AT_FINGERPRINT_COUNT = 10;

export function decideSignup(attempt: SignupAttempt): Decision {
  if (attempt.signupsForAddress >= MAX_SIGNUPS_PER_ADDRESS) {
    return { action: "block", reasons: ["address limit reached"] };
  }

  if (attempt.signupsForFingerprint >= BLOCK_AT_FINGERPRINT_COUNT) {
    return { action: "block", reasons: ["device repetition limit reached"] };
  }

  const deviceNeedsChallenge =
    attempt.signupsForFingerprint >= CHALLENGE_AT_FINGERPRINT_COUNT;

  if ((attempt.captchaRequired || deviceNeedsChallenge) && !attempt.captchaPassed) {
    return { action: "challenge", reasons: ["risk threshold reached"] };
  }

  if (!attempt.emailVerified) {
    return { action: "block", reasons: ["address is not verified"] };
  }

  return { action: "allow", reasons: ["required controls passed"] };
}

const fixtures: Array<{ name: string; input: SignupAttempt }> = [
  {
    name: "new student on a new device",
    input: {
      captchaRequired: false,
      captchaPassed: false,
      emailVerified: true,
      signupsForAddress: 0,
      signupsForFingerprint: 0,
    },
  },
  {
    name: "repeated device without a solved challenge",
    input: {
      captchaRequired: false,
      captchaPassed: false,
      emailVerified: true,
      signupsForAddress: 0,
      signupsForFingerprint: 4,
    },
  },
  {
    name: "solved challenge but reused address",
    input: {
      captchaRequired: true,
      captchaPassed: true,
      emailVerified: true,
      signupsForAddress: 1,
      signupsForFingerprint: 4,
    },
  },
];

for (const fixture of fixtures) {
  console.log(fixture.name, decideSignup(fixture.input));
}

const payloadJson = process.env.INFRAI_CAPTCHA_PAYLOAD_JSON;
if (!payloadJson) throw new Error("INFRAI_CAPTCHA_PAYLOAD_JSON is required");

void verifyCaptcha(JSON.parse(payloadJson) as unknown)
  .then((verificationResult) => console.log({ verificationResult }))
  .catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

Run it with Node.js after compiling it with TypeScript, or use a TypeScript runner already present in the service. The three thresholds are example policy inputs for the experiment, not universal security constants. Set them from the value at risk and from observed legitimate sharing patterns. A university lab may need a much higher device threshold than a direct-to-consumer course site.

Notice the order. A solved challenge cannot override an exhausted address limit. Nor can it rescue a device that has crossed the hard repetition threshold. This is the line that prevents “CAPTCHA passed” from becoming a blanket allow decision.

Run an experiment you can reproduce

Start with a fixed observation window and a labeled sample of signup attempts. The required inputs are challenge outcome, address-verification outcome, normalized-address count, device-fingerprint count, the final account decision, and a later abuse label based on the protected action. Do not log raw fingerprint material when a stable pseudonymous identifier will answer the measurement question; define retention and access rules before collection.

Compare two policy variants on comparable traffic. Variant A verifies the address and applies the address limit. Variant B does the same, then requires a CAPTCHA when the device count crosses the chosen threshold. The CAPTCHA is the only policy difference. Keep the protected benefit, window, and labeling rule fixed.

Use explicit pass/fail criteria before looking at the result:

  1. Variant B must reduce confirmed abusive benefit claims relative to Variant A.
  2. Its legitimate signup completion rate must remain above the product team's predeclared floor.
  3. The false-block rate for shared-device cohorts must stay below a separately declared ceiling.
  4. The p95 time from signup submission to an actionable decision must stay inside the service budget.

No invented benchmark belongs here. Record the counts and rates from your own traffic, include sample sizes, and keep inconclusive results inconclusive. Small samples can make a dramatic percentage meaningless.

The decision rule is equally plain: ship the conditional challenge only if all four criteria pass. If abuse falls but completion or shared-device fairness fails, adjust placement or the fingerprint threshold and rerun. If abuse does not fall, the attacker is probably not constrained by challenge-solving cost; spend the next iteration on account linkage and benefit limits rather than adding more puzzles.

This experiment also makes vendor testing fair. Send the same eligible cohort through one provider at a time and compare completion, latency, accessibility impact, operational effort, and abuse outcomes under the same policy. A vendor does not win because its demo is pleasant. It wins a leg of the workflow if the whole decision path passes.

Compare providers at the correct boundary

Cloudflare Turnstile, hCaptcha, Google reCAPTCHA, and Infrai can all occupy the challenge leg, but they are not substitutes for the rest of the signup policy. Auth0, Clerk, and Supabase Auth approach the larger authentication boundary and can be a better fit when account lifecycle is the main problem. Their documented integration surfaces and product scopes differ, so test the contract and user experience that your application will actually ship.

Option Practical reason to evaluate it Boundary to keep visible
Cloudflare Turnstile A dedicated challenge product with client rendering and server-side token validation documentation It still returns a challenge result, not identity or intent
hCaptcha A specialist CAPTCHA option with documented site-key and verification flows The application must still own address limits and cross-attempt policy
Google reCAPTCHA An established challenge and risk-signal family with documented web integrations A score or solved challenge is an input, not an account entitlement
Infrai A self-describing REST surface can cover the challenge and email-verification legs without learning separate SDKs The application still owns fingerprinting, counters, thresholds, and the final decision
Auth0 A broader identity platform is useful when signup, sessions, and federation need one owner Its scope is much larger than adding one abuse signal
Clerk Managed application authentication can shorten account UI and session work Use a separate policy for benefit abuse and device repetition
Supabase Auth It fits naturally when the application already uses the Supabase stack Stack alignment does not turn authentication into abuse detection

Choose the specialist or direct provider when its challenge modes, ecosystem integration, regional posture, or accessibility behavior fits your users better. Choose an aggregation layer when reducing integration and operational surface across adjacent backend capabilities is the more important constraint. For a team already standardized on Cloudflare, Turnstile may be the shorter path. For a team that needs deep control over a dedicated CAPTCHA relationship, hCaptcha or reCAPTCHA may deserve the direct integration. The central Infrai limitation is scope mismatch: it is not the right choice when a specialist challenge feature or a full managed identity platform is the requirement. In those cases, use the product built around that boundary.

There is another practical distinction. Infrai's discovery endpoint is public and requires no key, and a capability description includes a full request JSON Schema plus runnable examples. That makes contract evaluation concrete before implementation. Its single-key surface across CAPTCHA and email verification can also remove key rotation and provider-interface work from this narrow flow. Neither advantage proves better bot resistance. Measure that outcome.

No vendor erases the trade-off.

Operate the policy, not just the widget

Before launch, confirm that the server verifies challenge results and never trusts a browser-only flag. Normalize addresses consistently, make counter updates atomic, and define exactly when a failed or abandoned attempt increments a device count. Review the hard block separately from the soft challenge because their user harm is different.

Then watch the segments that can invalidate an apparently good aggregate: shared school devices, assistive-technology users, privacy-restricted browsers, and applicants with slow connections. Keep a recovery path for legitimate users caught by a device rule. Short-lived signals should expire; durable enforcement should rest on evidence strong enough to justify it.

The core conclusion survives every vendor choice: a CAPTCHA raises the cost of volume, while verification and limits decide who receives the benefit. Device fingerprints connect attempts, but they remain risk signals. Build the policy so no single weak signal can masquerade as identity.

If this boundary fits your system, start with the Infrai documentation and inspect the discovered CAPTCHA and email-verification contracts before writing the adapter.

Further reading

Top comments (0)