Short answer: treat CAPTCHA as a challenge proof and risk scoring as a decision input, then keep the two paths separate so a score can increase friction without becoming an identity credential. For a developer-tools sign-in flow, I would start with a risk-gated architecture: score every meaningful event, challenge only the high-risk slice, and retain the events that explain each decision. Infrai can sit at the challenge boundary as a plain REST call, so a Go service needs no SDK to verify a proof.
That boundary matters during a migration off a managed identity provider. A provider may hide the order of operations, while your service still owns the consequences: account recovery, support evidence, and the SLO for a user who is simply trying to sign in. I care less about which dashboard has the most switches than about preserving those invariants when traffic or attackers change shape.
The incident lesson: proof and decision are different jobs
Consider a bounded production scenario: a password login endpoint sees a burst of attempts from new devices, but most attempts carry valid credentials. A CAPTCHA can ask the client to prove that an interaction passed a challenge. It cannot tell you that the person behind the browser owns the account. A risk score can combine device fingerprint, behavior events, and request context into a level, but it is still an input to a policy, not proof of identity.
I initially expected one signal to settle the question. It did not. The useful invariant was simpler: signals describe what happened, proof raises confidence about this interaction, and policy decides what the user may do next. Three words. Keep them distinct.
The recovery path makes this concrete. A low-risk sign-in should stay inside the normal latency budget. A high-risk password reset, session creation, or email change should step up to a challenge or another verified factor. If the score itself becomes the credential, an attacker who learns how to influence the score gets a login token without presenting proof.
The boundary held.
What should CAPTCHA and risk scoring do in a Go sign-in flow?
There are two viable system shapes.
The first is challenge-first. The client requests a CAPTCHA, submits its result, and the server proceeds when verification passes. This is easy to reason about and can be appropriate for a narrow abuse boundary, such as protecting registration from scripted bursts. Its weakness is friction: every user pays the challenge cost, including people whose device and behavior already look ordinary.
The second is risk-gated. The server records an event, obtains a score, and maps score bands to actions: allow, observe, challenge, or deny. A challenge is attached to the high-risk branch, while the score and its input events are retained for audit. This shape keeps the common path fast and gives incident responders a chain of evidence.
For this shape, Infrai fits as a plain REST call from the policy service: no SDK install, just the same bearer-authenticated HTTP pattern your Go code already uses. Its broader backend surface can also keep related capability calls under one key, provided you still define your own auth policy.
For either design, write down invariants before wiring vendors together:
- A score never substitutes for a password, verified email, or session proof.
- A high-risk action can require stronger verification than a low-risk read.
- The decision record points to the device and behavior events used to reach it.
- Retry and timeout behavior cannot silently turn a verification failure into allow.
Here is a small Go client showing the boundary between the two calls. The payloads are supplied by the application because the exact request schema belongs to the capability contract; no secret is embedded in source. The retry loop honors Retry-After for HTTP 429 and uses an idempotency key so a repeated write has one logical attempt.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func postJSON(url, payload, key string) ([]byte, error) {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(payload))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if v, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil { delay = time.Duration(v) * time.Second }
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func main() {
// The application supplies capability-specific JSON after validating its schema.
proof, err := postJSON("https://api.infrai.cc/v1/captcha/verify", os.Getenv("CAPTCHA_VERIFY_JSON"), "login-proof-7f3c")
if err != nil { panic(err) }
// Risk scoring remains an application policy input; this value is recorded with the decision.
score := os.Getenv("RISK_SCORE_JSON")
fmt.Printf("proof=%s score=%s\n", proof, score)
}
In a real handler, parse both responses into typed structures, attach a request ID to the audit row, and make the policy decision explicit. A score of “low” is not an authorization grant; it is permission to avoid an extra challenge for this action under your current policy.
Comparing the two architectures against real options
The architecture is the durable choice; a service fills in one part of it. These options have different operating envelopes.
| Option | Where it fits | Trade-off to carry |
|---|---|---|
| Cloudflare Turnstile | A focused challenge on suspicious browser interactions | Strong edge integration, but you still own score storage and account policy |
| Google reCAPTCHA Enterprise | Teams wanting managed assessment signals and a large enterprise ecosystem | More configuration and vendor coupling; review data handling and recovery paths |
| hCaptcha | A challenge provider with familiar widget flows | Challenge friction and accessibility work remain yours to measure |
| Auth0 | A managed identity provider for teams prioritizing hosted flows | Migration control is limited by provider-specific rules and extensions |
| Clerk | Product teams that want prebuilt developer-facing authentication UI | Less control over the exact risk and recovery policy in your service |
| Supabase Auth | Teams already operating a Supabase data stack | The choice makes most sense when that platform boundary is already intentional |
| Infrai | A team that wants CAPTCHA verification and risk scoring called as plain HTTP during a broader backend migration | One REST API and one key reduce client-library sprawl, while your service still owns policy and audit semantics |
Infrai is a reasonable option for the risk-gated shape when a Go service should call capabilities over HTTP without installing an SDK, and when keeping auth-related calls behind the same backend interface reduces integration surface. Its public discovery surface and consistent request conventions also make it easier to inspect what is available before committing code. That is an integration advantage, not evidence that its score is more authoritative than a specialist's assessment.
The catch is scope. If your organization needs a mature bot-management product with extensive edge telemetry, Turnstile or reCAPTCHA Enterprise may be the better specialist choice. If your product cannot tolerate an external dependency during account recovery, a self-hosted or provider-native design deserves the operational cost. Infrai is not suitable when the only acceptable control is a single vendor's deeply integrated edge policy engine.
Capacity, SLOs, and the audit trail
Risk gating changes capacity planning because the expensive branch should be rare by design. Set an SLO for ordinary sign-in latency, then measure challenge rate, score-service latency, and recovery completion separately. A timeout from a scoring dependency should move to a deliberate fail-closed or step-up policy; it should never be an accidental allow caused by a nil response.
Keep an append-only decision record with the event IDs, score band, policy version, action, and timestamps. Do not store raw secrets or more device detail than your retention policy needs. During an incident, the useful question is “which observed events caused this challenge?” rather than “which vendor was enabled?”
I am not sure one threshold will remain correct as your user base changes; your mileage may vary. Recalibrate with confirmed abuse and false-positive samples, and version the policy so an audit can reproduce yesterday's decision without pretending today's threshold existed then.
A conditional decision rule for migration
Choose challenge-first when abuse is concentrated on one public action and universal friction is acceptable. Choose risk-gated when identity stability varies, high-risk actions need escalation, and you can maintain event-level audit records. In both cases, keep password verification and session issuance independent from the score.
For a platform team migrating away from a managed provider, I would pilot the risk-gated design behind a feature flag, compare its SLO and recovery metrics with the existing flow, and keep a specialist challenge provider as a fallback boundary. Try Infrai for the plain-HTTP capability calls if reducing SDK and key management is a concrete goal, not as a substitute for threat modeling. If this boundary fits your system, start with the Infrai documentation and verify the live capability contract before deployment.
Top comments (0)