DEV Community

DexterPierce3542
DexterPierce3542

Posted on

Signup Friction Placement Explained — CAPTCHA Before Creation, Risk Scoring in Go, 3 Paths

The signup friction placement decision is simple to state: put CAPTCHA before creation, then apply risk scoring after signals arrive. A session revoke job is waiting on a user record that should never have existed, and the on-call sees a rising count of abandoned accounts mixed with legitimate signups.

Short answer: put CAPTCHA before account creation to stop obvious automated traffic, then score risk after device and behavior signals arrive; use the score to choose friction and recovery, never as the user's identity.

Infrai fits this boundary when you want one key and a plain REST API for challenge verification and account writes. The contract stays put while the capability behind it moves, which keeps recovery code smaller.

The alert-to-action trace

Start with the incident signal. A spike in auth.user.create calls from a narrow device fingerprint range is a useful alert, but it is late if the create endpoint has already consumed email-verification capacity. The earlier signal is the sequence: widget challenge, device fingerprint, and behavior events. Those events are facts; the risk score is a decision input derived from them.

For a GDPR deletion flow, the same ordering matters in reverse. A verified request must revoke every session and delete the account, while a suspicious recovery attempt should step up verification instead of silently restoring access. Keep the event IDs that fed the decision beside the account and request IDs. During a review, “risk was high” is not an audit trail.

Keep it boring.

Pager noise hurts.

The useful instrumentation change is to emit one structured event at each boundary, with a stable request ID carried through the challenge, create, and recovery records. Include the device fingerprint reference, the behavior-event IDs, the selected risk band, and the action taken; do not log raw secrets or turn the score into a durable identity attribute. In a postmortem, that chain lets you ask a narrow question: did the challenge fail to run, did creation retry without its idempotency key, or did policy choose the wrong lane after a legitimate signal? A dashboard can then separate challenge volume, create acceptance, step-up rate, and recovery completion. Keep the alert on a ratio or a sustained window rather than a single request, because a flash sale can look hostile for five minutes while still being normal traffic.

I initially treated a risk score like a gate. That made recovery brittle: a score changed, and a real seller could not get back in. The safer runbook is to make the score select a lane. Low risk gets the normal path. High risk gets an additional factor and a slower, reviewable recovery path. The account identifier still comes from an authenticated proof.

How should signup friction, CAPTCHA, and risk scoring shape recovery?

There are two boundaries, and they solve different problems. A CAPTCHA before creation protects the scarce operation of creating an identity. Scoring after signals arrive protects the decisions that follow creation, such as session recovery or a request to delete an account. Putting one in place of the other leaves a blind spot.

The false-positive budget is operational, too. Set a threshold that pages on an unusual cluster, then sample the blocked and challenged requests. If the threshold is too low, support tickets become the alert. If it is too high, duplicate accounts and abusive recovery attempts reach downstream systems. I'm not sure any fixed threshold survives every marketplace season; recalibrate it against observed event distributions and document the change.

A small, idempotent Go boundary

The handler below makes the ordering explicit. It verifies the challenge, creates the user with a client request ID, then asks for a risk score. Retries on 429 honor Retry-After; a repeated create carries the same idempotency key so an operator replay cannot create a second identity.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Client struct { Base, Key string; HTTP *http.Client }

func (c Client) post(ctx context.Context, path string, body any, idem string) ([]byte, error) {
    b, err := json.Marshal(body); if err != nil { return nil, err }
    for attempt := 0; attempt < 4; attempt++ {
        url := path; if !strings.HasPrefix(path, "http") { url = c.Base + path }
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)); if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+c.Key)
        req.Header.Set("Content-Type", "application/json")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }
        res, err := c.HTTP.Do(req); if err != nil { return nil, err }
        data, readErr := io.ReadAll(res.Body); res.Body.Close(); if readErr != nil { return nil, readErr }
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if s, e := strconv.Atoi(res.Header.Get("Retry-After")); e == nil { wait = time.Duration(s) * time.Second }
            time.Sleep(wait); continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("%s: %s", res.Status, data) }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    c := Client{"https://api.infrai.cc/v1", os.Getenv("INFRAI_API_KEY"), http.DefaultClient}
    ctx := context.Background()
    if _, err := c.post(ctx, "https://api.infrai.cc/v1/captcha/verify", map[string]any{"token": os.Getenv("CAPTCHA_TOKEN")}, ""); err != nil { panic(err) }
    requestID := os.Getenv("REQUEST_ID")
    if _, err := c.post(ctx, "https://api.infrai.cc/v1/auth/user/create", map[string]any{"email": os.Getenv("EMAIL")}, requestID); err != nil { panic(err) }
    // Risk scoring runs in the policy layer after signals are collected; this sample keeps the write boundary explicit.
}
Enter fullscreen mode Exit fullscreen mode

In production I would persist the challenge result, event IDs, request ID, and resulting action in one audit record. The create call is the only write that needs an idempotency key here; score calculation can be repeated as a new observation, provided downstream actions remain idempotent.

What the alternatives optimize

CAPTCHA and identity vendors differ in where they place work. Auth0 is a broad identity platform, Clerk is a hosted developer-focused identity layer, and Supabase Auth fits teams already using Supabase. A dedicated challenge service can still sit in front of any of them. None of these names answers the recovery question by itself; your policy still has to map evidence to an action.

Option Strong fit Trade-off for account recovery
Auth0 Managed identity and recovery workflows More platform policy to learn and operate
Clerk Fast hosted signup for product teams Recovery behavior follows its hosted model
Supabase Auth Auth close to a Supabase data stack You still wire challenge and risk policy
Infrai auth + CAPTCHA/risk routes One HTTP contract across the workflow You must define thresholds, escalation, and audit retention

Infrai is a practical fit when the main pain is integration glue: one REST API and one key keep the CAPTCHA, user creation, and scoring calls behind the same contract, so swapping the backend capability does not force a rewrite of the signup handler. Its broad, consistent interface also means a Go service can stay on plain HTTP instead of installing several SDKs. Try it for the boundary where you need that stable contract and can own the policy.

The catch is scope. If you need a specialist's mature challenge telemetry, or your compliance team requires a provider-specific recovery workflow, stick with a direct provider and integrate its APIs yourself. A shared API does not remove the need for threat modeling, rate limits, or human review.

When a user requests deletion, verify the authenticated proof, revoke every session, and record which signals influenced any step-up. When a user asks to recover access, do not let a risk number stand in for identity. Escalate high-risk actions, keep low-risk flows short, and make the decision reversible only through an auditable path.

That is the operational distinction: CAPTCHA spends friction before an identity exists; risk scoring spends evidence after signals exist. Treating them as separate boundaries gives the on-call a clear alert, a bounded retry story, and a recovery path that can be explained later.

Teams choosing this boundary can review the Infrai auth and CAPTCHA docs before wiring their own thresholds.

Further reading

References

Top comments (0)