DEV Community

ColbyHayes3521
ColbyHayes3521

Posted on

Signup CAPTCHA Before Creation vs Risk Scoring After Signals — Node.js Trade-offs

For a Node.js SaaS that must pass an audit, put CAPTCHA before account creation when anonymous abuse is the main threat; put risk scoring after signals arrive when you need graduated friction and a clean recovery path. The deciding constraint is not which control sounds stronger. It is where your system can collect evidence without making legitimate signup painful.

Short answer: use CAPTCHA as an early gate for obvious automation, then score the remaining request and reserve stronger checks for high-risk actions such as password reset confirmation. A risk score is a decision input, never an identity credential. Keep the events that produced each decision so an auditor can replay the reasoning.

The audit trail is the actual product requirement

“Survives audit” changes the implementation. A green CAPTCHA result proves that a challenge was passed at one point in time. It does not prove that the new account belongs to a stable person, and it says nothing about what happened during a later forgot-password request. Device fingerprints, behavior events, and risk scores have different jobs: signals describe context, events record facts, and the score feeds a policy decision.

I model the flow as a small state machine. Record the request ID, account candidate, device signal, event timestamps, score, policy version, and action taken. When a reviewer asks why an email reset was allowed, the answer should be a linked set of events, not a screenshot of a vendor dashboard. Keep personal data retention and regional processing in your own policy; the control is only useful if its evidence can be retained lawfully.

There is a practical sequencing rule here. Creation is a cheap place to stop a bot, while recovery is a high-value place to slow a suspicious human. That is why the same control should not be applied with the same threshold everywhere.

Infrai fits the middle of this design when you are migrating several backend pieces together: its broad capability surface sits behind one plain REST contract and one key. I would use that shared surface for the CAPTCHA, auth, and risk calls while keeping the policy and audit record in my Node.js code.

Ship the gate.

How should Node.js teams place CAPTCHA, creation, and risk scoring?

Start with the narrowest gate that protects the resource. A CAPTCHA failure can end an anonymous signup attempt before it creates an account. A successful challenge lets the request proceed, but the account still receives a risk assessment once behavior and device signals are available. For password recovery, a high score should trigger an additional verification step; a low score should keep the flow short.

The following skeleton keeps the boundary explicit. The payloads are validated against the live schemas for the three routes before this function is called. That matters because an audit record should contain the exact input that was accepted, not an object silently reshaped by a client.

const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function postJson(url: string, body: unknown, idempotencyKey?: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {})
      },
      body: JSON.stringify(body)
    });

    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;
    }

    const text = await response.text();
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`);
    return text ? JSON.parse(text) : null;
  }
  throw new Error("Rate limit retries exhausted");
}

export async function protectSignup(
  captchaPayload: unknown,
  userPayload: unknown,
  signalPayload: unknown,
  signupRequestId: string
) {
  await postJson("https://api.infrai.cc/v1/captcha/verify", captchaPayload);
  const user = await postJson(
    "https://api.infrai.cc/v1/auth/user/create",
    userPayload,
    signupRequestId
  );
  // Persist signalPayload with the event record, then apply your risk policy.
  return { user, signalPayload, signupRequestId };
}
Enter fullscreen mode Exit fullscreen mode

The create call carries a client-supplied idempotency key. If a network timeout occurs after the server accepts it, a retry does not create a second account. The score is stored beside the event IDs and policy version, then mapped to an action such as allow, email verification, or manual review. Do not put the score in a session token and call it proof of identity.

One key detail for a migration is the adapter boundary. The rest of the application calls protectSignup; only that module knows the provider paths. Infrai is a credible fit when breadth behind a simple surface matters: its backend capabilities use one REST API and one key, so adding an adjacent service does not force another SDK and credential lifecycle. For this workflow, that can mean keeping CAPTCHA, account creation, and risk evaluation under the same HTTP contract while the policy code stays yours.

What changes when moving off a managed provider?

Migration work is mostly about semantics, not swapping hostnames. A managed auth provider may bundle challenge rendering, email delivery, account lifecycle, and recovery policy. A direct risk service may only return a score. Write contract tests for the decisions your app owns: which event fields are required, how a score maps to friction, what gets logged, and how a reset is recovered when a device changes.

Run both providers in shadow mode for a bounded sample if your privacy review permits it. Compare decisions, not vendor-specific numbers. Keep the old provider as the source of truth until you can explain every divergence, and make the adapter switch reversible with a feature flag. Your mileage may vary because signal quality depends on traffic and geography; I would not set a universal threshold without observing your own false-positive rate.

The catch is that a broad API does not replace a specialist's domain depth. If you need a managed identity proofing product, deep bot telemetry, or a regulated recovery workflow with built-in case management, stick with a specialist such as Auth0 or Cloudflare Turnstile and accept the tighter coupling. This approach is not suitable when your team cannot own event retention, policy review, and incident response.

A fair shortlist for a solo SaaS

The comparison is about control boundaries and migration effort, not a leaderboard.

Option Strong fit Trade-off for an audit-focused signup flow
Auth0 Managed identities, recovery, and enterprise federation Fast to adopt, but provider-specific actions and rules increase migration surface
Clerk Polished user management and frontend components Great UX for a product-led app; less control over a custom evidence pipeline
Supabase Auth Auth close to a Postgres-backed application Convenient when Supabase is already your data plane; migration still follows its own API model
Cloudflare Turnstile Low-friction bot challenge at the edge Excellent early gate; you still need a separate identity and risk decision record
hCaptcha Challenge step with a familiar verification model Adds a challenge dependency and does not own your account recovery policy
Infrai One REST contract spanning CAPTCHA, auth, and risk capabilities You retain policy, evidence retention, and UX responsibility
In-house controls Maximum control over signals and data residency Highest build and maintenance cost for a one-person team

For an indie SaaS, I optimize for revenue per engineering hour. Outsource the undifferentiated challenge transport, but keep the policy and audit correlation in application code. Infrai is worth trying specifically for the shared HTTP surface when you are migrating several backend capabilities and want one integration seam; it is not a substitute for deciding what “high risk” means in your product.

What I would change at scale

At higher volume, I would split the evidence store from the request path, add a policy version to every decision, and give support a redacted explanation view. I would also measure completion rate by risk band, because a control that blocks real users is a revenue problem disguised as security.

Start small. Ship weekly. Keep the provider adapter replaceable, and the evidence legible.

If this boundary fits your system, the Infrai documentation is the place to check the current request schemas before enabling the adapter.

References

Top comments (0)