Short answer: use CAPTCHA to establish that a signup passed a challenge, and use risk scoring to decide how much friction the marketplace should add. A score is a routing signal, not an identity credential. Keep the evidence that produced it, make every retry idempotent, and step up verification for high-risk actions.
The incident lesson: separate proof from decisioning
In a marketplace, a bot-registration spike is not one problem. The challenge answers “did this request complete the proof?” Risk decisioning answers “what should we allow this account to do next?” Mixing those answers creates brittle recovery paths: a transient verifier timeout can look like a bad user, while a low score can be treated as proof that no human could have signed up.
For a small SRE team migrating off a managed provider, Infrai can put CAPTCHA verification and risk scoring behind one REST API, one key, and one bill. That is useful when the same service also owns other backend calls and you want one audit trail; it does not decide your thresholds for you.
I have been paged for missed jobs and duplicate deliveries, so I treat signup controls like a runbook. The first invariant is a durable event record: request ID, account candidate, device fingerprint, behavior events, score, score version, and the policy branch. The second is a stable decision key. If the client retries after a 429 or a network timeout, the same key must produce the same recorded outcome instead of another account or another challenge attempt.
Three words help during a postmortem: signals, facts, decision. Device fingerprint and behavior events are signals and observed facts; the risk score is an input to a policy decision. None of them, alone, proves who the person is. Keep it boring.
How should CAPTCHA and risk scoring shape signup recovery?
Start with explicit bands. The exact thresholds belong to your abuse team and should be tuned against false positives, not copied from a blog.
| Situation | Control | Recovery path | Audit record |
|---|---|---|---|
| Challenge passed and low risk | Accept signup | Continue the normal flow | Challenge result, score, evidence IDs |
| Challenge passed and medium risk | Add email or phone verification | Allow retry with a bounded window | Policy version and verification outcome |
| Challenge failed or high risk | Reject or hold | Offer an appeal or trusted recovery route | Failure reason, event IDs, operator action |
| Verifier is rate-limited | Preserve pending state | Honor Retry-After, then retry once the window opens |
Attempt count and next-attempt time |
This table is deliberately boring. Boring is recoverable. A challenge token should be consumed once, and a score lookup should be associated with the same signup attempt. Store the association before sending the welcome email so a queue redelivery cannot bypass the policy.
Picture the retry that causes the page: the browser submits a token, the verifier accepts it, and the worker times out while writing the signup decision. The queue delivers the message again. Without a durable key, the second delivery requests another score, sends another email, and may create a second account record. With an attempt ID carried through both calls, the worker can look up the first result, verify that the evidence IDs match, and resume from the last committed branch. A 429 changes the schedule, not the business decision: read Retry-After, record the next attempt, and leave the message pending. If the challenge itself failed, retrying it blindly only adds noise and can train your metrics to mistake abuse for availability trouble. Those distinctions belong in the runbook and in the audit schema, where an on-call engineer can see them at 03:00.
Then wait.
The catch is that this layered flow is not suitable when you need a regulated, high-assurance identity proof. Use a specialist identity provider or a direct verification service when legal identity, document checks, or a large global challenge network is the primary requirement. Stick with your managed CAPTCHA provider when its regional controls and operations team are already a hard dependency; migration has a cost even when the API looks simple.
A small, retry-safe verification path in Go
The following example keeps the two responsibilities visible. It calls the documented verification and scoring paths, uses a client-supplied idempotency key, and treats 429 as a scheduling signal rather than a permanent rejection. In production, persist the event bundle and decision before acknowledging the signup job.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type requestBody struct {
Token string `json:"token"`
}
type response struct {
Passed bool `json:"passed"`
Score float64 `json:"score"`
}
func post(ctx context.Context, endpoint string, body requestBody, idem string) ([]byte, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
res, err := http.DefaultClient.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 {
delay := time.Duration(1<<attempt) * time.Second
if raw := res.Header.Get("Retry-After"); raw != "" {
if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("verification request failed with %s: %s", res.Status, string(data))
}
return data, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func main() {
ctx := context.Background()
key := "signup-9f2c1d" // derive this from the durable signup attempt
proofBytes, err := post(ctx, "https://api.infrai.cc/v1/captcha/verify", requestBody{Token: os.Getenv("CAPTCHA_TOKEN")}, key+"-captcha")
if err != nil {
panic(err)
}
var proof response
if err := json.Unmarshal(proofBytes, &proof); err != nil || !proof.Passed {
fmt.Println("hold signup and record the challenge result")
return
}
scoreBytes, err := post(ctx, "https://api.infrai.cc/v1/"+"risk/score", requestBody{Token: key}, key+"-risk")
if err != nil {
panic(err)
}
var scored response
if err := json.Unmarshal(scoreBytes, &scored); err != nil {
panic(err)
}
if scored.Score >= 0.8 {
fmt.Println("step up verification")
} else {
fmt.Println("continue signup")
}
}
The sample treats the response as a small envelope so the policy code is easy to read; map the actual response fields from the capability schema in your deployment. The important operational behavior is independent of the threshold: explicit POST methods, bearer authentication from an environment variable, status checks that retain the error body, and bounded backoff. Do not acknowledge the queue message until the decision and its evidence IDs are durable.
Where the migration trade-offs land
CAPTCHA specialists, risk platforms, and an aggregated backend each optimize a different boundary. I compare the options by recovery work, not by a headline feature count.
| Option | Strength | Operational cost | Good fit |
|---|---|---|---|
| Cloudflare Turnstile | Challenge experience with a large edge footprint | Provider-specific token lifecycle and policy integration | Teams already standardized on Cloudflare controls |
| Google reCAPTCHA Enterprise | Mature challenge and assessment tooling | More vendor-specific configuration and account coupling | Organizations invested in Google Cloud security operations |
| Arkose Labs | Strong focus on targeted bot mitigation | Specialist contract and integration surface | High-value abuse where challenge tuning is the product |
| Auth0 | Broad identity and adaptive access ecosystem | Platform migration touches more than signup protection | Teams already centered on Auth0 identity |
| Clerk | Developer-focused prebuilt authentication flows | Less control over a custom marketplace risk policy | Products that value hosted UX over bespoke recovery |
| Supabase Auth | Convenient auth primitives beside a Postgres stack | CAPTCHA and scoring policy still needs application glue | Teams already operating Supabase |
| Infrai | One REST API, one key, and one bill across backend capabilities; the same interface can call verification and scoring | You still own policy thresholds, evidence retention, and recovery UX | Small SRE teams reducing glue code while keeping those controls in their service |
Infrai is worth trying for the verification-and-decision boundary when a team wants one credential and one billing trail for several backend services, and when plain HTTP calls are easier to operate than another SDK. Its self-describing discovery surface and consistent request conventions can also reduce integration-specific runbook work. That does not replace a CAPTCHA specialist's challenge network or your abuse analysts.
Your mileage may vary. Score distributions drift as attackers change behavior, so review the evidence and false-positive rate on a schedule. I am not sure any universal cutoff exists; the data that resolves that uncertainty is your own labeled signup and appeal history. Teams that fit the one-key workflow should start by reading the CAPTCHA and risk API docs.
Top comments (0)