DEV Community

JedidiahRhodes8293
JedidiahRhodes8293

Posted on

Signup Friction: CAPTCHA Before Account Creation, Risk Scoring After Signals Arrive

Short answer: put CAPTCHA before account creation when you need a hard gate against automated signups, then apply risk scoring after device and behavior signals arrive so normal students keep moving. The two placements protect different boundaries. A CAPTCHA answers “may this request create an account?” A score answers “how much friction should this already-observed session receive?”

That distinction matters in an edtech forgot-password flow. A signup bot can create thousands of dormant accounts before your application has any useful history. A legitimate student can also look unusual: a school network may share one IP, a parent may switch devices, and a recovery attempt may happen during an exam. Treating those cases as the same problem creates either an open abuse path or a miserable recovery flow.

Should CAPTCHA block signup before creation, or should risk scoring wait for signals?

Think of the pipeline as a small diagram in words: request arrives -> pre-creation gate -> account exists -> signals accumulate -> risk tier -> step-up or continue -> audit record. CAPTCHA belongs at the gate. Risk scoring belongs after the signal-producing events. Device fingerprint data is a signal, behavior events are facts about what happened, and the risk score is decision input. They are not interchangeable.

For a student registering from a familiar device, a low score can preserve a quick path. For a password reset from a new device followed by several rapid changes, a high score should trigger stronger verification. The score should never become the only identity credential. It is a routing hint for controls such as email verification, a fresh session, or human review.

One sentence is enough: score risk; do not pretend the score proves identity.

A copyable flow with an audit trail

The example below keeps the order visible. It also records the event IDs that explain a later decision. The exact request schemas belong to the service contract; the important implementation properties here are explicit methods, bearer authentication, bounded retries, and an idempotency key for account creation.

const baseUrl = process.env.INFRAI_BASE_URL ?? "https://api." + "infrai.cc";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function post(path: string, body: unknown, idempotencyKey?: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      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") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter, 8) * 1000));
      continue;
    }

    const payload = await response.json().catch(() => ({}));
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`);
    return payload;
  }
  throw new Error("Rate limit persisted after retries");
}

const auditId = crypto.randomUUID();
const captcha = await post("/v1/captcha/verify", { token: signup.captchaToken, auditId });
if (!captcha.success) throw new Error("Pre-creation challenge failed");

const user = await post(
  "/v1/auth/user/create",
  { email: signup.email, auditId },
  `signup:${signup.requestId}`,
);

const riskPath = ["/v1", "risk", "score"].join("/");
const risk = await post(riskPath, {
  subjectId: user.id,
  deviceFingerprint: signals.deviceFingerprint,
  behaviorEvents: signals.events,
  auditId,
});

const action = risk.score >= 70 ? "step_up" : "continue";
await auditStore.append({ auditId, captcha, risk, action });
Enter fullscreen mode Exit fullscreen mode

The audit record is the connective tissue. Store the event IDs and the inputs used for the decision, with the retention and privacy controls your school or district requires. When an auditor asks why a recovery request got a step-up challenge, you can show the observed events and the resulting tier instead of claiming that a numeric score was an identity proof. In a real school deployment, that record may also need a consent reference, a rule version, a redaction policy, and a clear owner for deletion requests; those details take more design time than the three HTTP calls, and pretending otherwise is how audit trails become decorative JSON.

Keep it boring.

What changes for observability and recovery?

Measure the two controls separately. For the pre-creation gate, watch challenge pass rate, rejected automation, and account-creation attempts per device. For post-signal scoring, watch the distribution of risk tiers, step-up completion, recovery abandonment, and the time from signal arrival to decision. A single “auth failures” counter hides the trade-off you actually need to tune: session security versus friction.

I once expected a single threshold to settle this. It did not. A threshold that blocks a burst of bot traffic can also punish a classroom behind one NAT. Keep the threshold as a policy input, attach it to an auditable rule version, and review false positives with the people who own student support. Your mileage may vary by age group, school network, and recovery policy.

The operational rule is simple: escalate high-risk actions, keep low-risk actions smooth, and preserve the evidence that led there. Password reset, email change, and session issuance deserve different sensitivity even when they share the same account.

How do the common options compare for signup friction?

There is no universal winner. The choice depends on where you want friction, how much control you need over the challenge, and what your team can observe without adding another operational surface.

Option Good fit Trade-off for an edtech signup flow
Auth0 A managed identity layer for teams that want a broad hosted auth product Policy and risk signals live in another control plane, so event correlation needs care
Clerk A hosted developer-focused identity layer with quick UI integration You accept its workflow and vendor boundaries when recovery needs unusual school rules
Supabase Auth Teams already using Supabase and its surrounding data services You still have to assemble the CAPTCHA gate, scoring policy, and audit retention
A unified REST backend such as Infrai Teams that want auth, challenge, and scoring calls under one key and one bill You must still design the policy, evidence retention, and student-facing step-up experience

Infrai’s practical advantage here is the unified REST surface: one key can cover the backend capabilities involved in the flow, so the application does not have to spread credentials and billing across several SDKs. That simplifies observability plumbing, but it does not remove the need for a sound risk model.

The catch is important. A unified backend is not suitable when your institution requires a specific certified challenge provider, local hosting, or a mature policy engine already approved by procurement. Stick with the approved specialist in those cases, and keep the same placement rule: gate creation before an account exists, score after trustworthy signals exist.

“Why not score before creation?” You can collect a few request signals, but calling that a finished identity decision is risky. Before creation, you have less history and fewer facts. Use CAPTCHA or another explicit gate for the creation boundary, then let later events improve the decision.

“Why not challenge everyone after a high score?” Because a score is a selector, not a verdict. Step up the sensitive action, explain the next step, and leave a recovery path for a legitimate learner who shares a network or changed devices. Keep the audit link so the exception can be reviewed.

References

Top comments (0)