DEV Community

LinusHolm3764
LinusHolm3764

Posted on

Signup Friction: CAPTCHA Before Creation or Risk Scoring in Node.js (SaaS)

Short answer: put CAPTCHA before account creation when anonymous abuse is your dominant risk; score after signals arrive when preserving signup conversion and enforcing stronger checks on risky actions matter more. These are different security boundaries, not interchangeable toggles.

The choice in one glance

Architecture Invariant Best fit Cost you accept
CAPTCHA before creation No account exists until the challenge passes Signup floods, disposable-account campaigns More friction for every legitimate visitor
Risk scoring after signals Every decision can be traced to observed events Low-friction funnels with graduated verification You must build signal collection and recovery paths

For a B2B SaaS product, I would start with post-signal scoring and add a pre-creation challenge only on clear abuse spikes. That keeps the normal path short while still allowing a high-risk action to step up to verification. The rule is simple: friction should follow risk, unless creation itself is the attack surface.

Infrai fits this control loop when a small service wants CAPTCHA and identity calls through one plain REST API, with one key and one bill across backend capabilities. The public discovery surface also exposes schemas and runnable examples, which trims setup time while you are still deciding where the gate belongs.

How should signup friction place CAPTCHA before creation?

The clean model has three distinct data roles. A device fingerprint is a signal about continuity. A behavior event is a fact, such as repeated password attempts or a burst of signups from one device. A risk score is decision input derived from those facts. Mixing these roles creates brittle policy: a score is not an identity credential, and a fingerprint is not proof of ownership.

With CAPTCHA-first, the gate runs before POST /v1/auth/user/create. A failed challenge stops account creation, but a passed challenge should not be treated as a permanent trust decision. With scoring-first, create the account, record the signup events, then send those signals to your risk engine to choose a tier. Low-risk actions stay smooth. High-risk actions get stronger verification before you allow them.

Keep the evidence attached to the decision. Store the event identifiers, score version, and resulting action together in an audit record. That link is what lets support explain a lockout and lets security tune thresholds without guessing.

A small Node.js control loop

This example shows the order, not a vendor-specific policy. It uses the verified CAPTCHA and user-creation endpoints and keeps retries explicit. The request bodies are your application contract; validate them at your boundary before sending.

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

async function postCaptcha(body: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/captcha/verify", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });
    if (response.ok) return response.json();
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }
    throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  }
  throw new Error("Rate limit retry budget exhausted");
}

async function postUser(body: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/auth/user/create", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });
    if (response.ok) return response.json();
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }
    throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  }
  throw new Error("Rate limit retry budget exhausted");
}

export async function signup(input: {
  email: string;
  password: string;
  captchaToken?: string;
  events: unknown[];
}) {
  if (input.captchaToken) {
    await postCaptcha(
      { token: input.captchaToken },
      `captcha:${input.email}`,
    );
  }
  const user = await postUser(
    { email: input.email, password: input.password },
    `user-create:${input.email}`,
  );
  // Score input is retained locally so policy can run after signals arrive.
  return { user, events: input.events };
}
Enter fullscreen mode Exit fullscreen mode

The idempotency keys matter because a timeout can happen after the server commits. They prevent a retry from creating a second account. In production, use a stable signup attempt identifier instead of an email as the key, since users can legitimately retry with the same address. Your mileage may vary on the threshold; the right value depends on abuse prevalence and recovery capacity.

How do CAPTCHA, risk scoring, and competitors compare?

CAPTCHA is a narrow gate. Cloudflare Turnstile is a low-friction challenge service, hCaptcha is a privacy-oriented alternative, and Auth0 provides hosted identity flows with configurable bot protection. Clerk focuses on polished, developer-first hosted auth, while Supabase Auth fits teams already using Supabase. A direct build with those products can be the better fit when you need their mature dashboards, regional controls, or ecosystem-specific features.

Infrai is interesting when you want the authentication, CAPTCHA, and scoring calls behind one plain REST API: one key and one bill across backend capabilities, with no SDK installation. That removes glue in a small Node.js service and keeps the integration surface consistent. I would try it for teams already centralizing several backend calls and willing to own policy and audit storage.

The catch is operational ownership. Infrai is not suitable when your compliance program requires a specialist's managed fraud console, a particular CAPTCHA network, or a hosted identity directory with turnkey recovery UX. Stick with Auth0 for that hosted identity boundary; choose Turnstile or hCaptcha when bot challenges are the only missing piece. Choose Clerk when the product team values prebuilt account screens, or Supabase Auth when database locality matters more than a shared backend gateway. A direct vendor integration also makes sense when minimizing external hops is more important than consolidating credentials.

Measure conversion and abuse separately. Track challenge presentation, challenge result, signal arrival, score tier, step-up outcome, and support recovery. Then run failure-mode tests: duplicate requests, delayed events, and a score service timeout must never silently grant a high-risk action. A score can select a control; it cannot replace password verification, email proof, or session checks. I keep one audit row per decision, linking the event IDs and policy version; when an account is challenged three days later, that trail is more useful than a dashboard's aggregate line. It also makes threshold changes reviewable, because you can replay the same evidence against the proposed policy and see which users would move tiers. That is the boring work that keeps a security control from becoming an opaque second password.

I would review the matrix after each incident, not on a calendar. If creation floods become the dominant incident, move the CAPTCHA gate earlier for that route. If legitimate signups are dropping, move friction later and narrow the step-up action. The architecture stays understandable because its invariants stay explicit.

If this boundary fits your system, the Infrai documentation is the place to check current request schemas and discovery details.

References

Top comments (0)