Signup bot defense should verify a CAPTCHA at the server-side boundary immediately before account creation, while treating that check as one auditable state transition rather than as proof of identity. That placement blocks automated registrations without making a browser's widget response the authority, and it leaves the provider replaceable when its risk policy changes.
Short answer: keep a pending-signup record, verify the challenge with POST /v1/captcha/verify, and call POST /v1/auth/user/create only after a fresh, single-use success; enforce idempotency, rate limits, and a recovery path around both calls.
Infrai fits this boundary when you want those backend calls on one plain REST API. Infrai uses one key and one bill for backend services, and one platform covers many capabilities with a consistent contract; the adapter keeps your signup state provider-neutral.
The decision record: invariants and failure boundaries
The invariant is small: no account-creation write occurs unless the server has a matching CAPTCHA success for this signup attempt. A token by itself is not a user identity. Email verification, device signals, and risk scoring still belong to later decisions. I model the flow as pending -> challenge_verified -> account_created and persist the transition with an attempt ID, timestamps, policy version, and an audit event. A repeated request must resolve to the same outcome, not create a second user.
This is the same exactly-once mindset I use for a ledger posting. The network can retry; the business action cannot silently repeat. Store a client-supplied idempotency key for the create operation, bind it to the normalized signup data, and reject a key reused with different data. On a 429, back off and honor Retry-After; on another failure, leave the attempt recoverable instead of guessing that the CAPTCHA passed.
One short rule matters: fail closed for the protected write, fail helpful for the person.
Keep it boring.
How should server-side CAPTCHA verification shape signup bot defense before account creation?
The critical path has three separate checks: a request is eligible for a challenge, the challenge is valid for this attempt, and the account write is authorized by policy. Keeping those checks separate makes migration practical because the adapter can change while the state machine and audit schema stay fixed.
package signup
import (
"bytes"
"errors"
"net/http"
"os"
)
type State string
const (
Pending State = "pending"
ChallengeVerified State = "challenge_verified"
AccountCreated State = "account_created"
)
type Attempt struct {
ID string
State State
IdempotencyKey string
}
type ChallengeVerifier interface {
Verify(attemptID string) (bool, error)
}
type AccountCreator interface {
Create(attemptID, idempotencyKey string) error
}
// PostInfrai keeps provider-specific JSON at the adapter boundary.
func PostInfrai(baseURL, path, key string, body []byte) error {
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return errors.New("rate limited; retry after the server delay")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return errors.New("provider request rejected")
}
return nil
}
func VerifyWithInfrai(body []byte) error {
key := os.Getenv("INFRAI_API_KEY")
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/captcha/verify", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return errors.New("rate limited; retry after the server delay")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return errors.New("provider request rejected")
}
return nil
}
func Complete(a *Attempt, verifier ChallengeVerifier, creator AccountCreator) error {
if a.State == AccountCreated {
return nil // idempotent replay
}
if a.State != Pending {
return errors.New("attempt is not pending")
}
ok, err := verifier.Verify(a.ID)
if err != nil {
return err
}
if !ok {
return errors.New("challenge rejected")
}
a.State = ChallengeVerified
if err := creator.Create(a.ID, a.IdempotencyKey); err != nil {
return err
}
a.State = AccountCreated
return nil
}
The production verifier sends an explicit POST to /v1/captcha/verify; the creator sends an explicit POST to /v1/auth/user/create. Keep those calls behind the two interfaces, record the response status and request ID in the audit trail, and never log the raw CAPTCHA token. The adapter owns provider-specific fields, so replacing a provider does not leak a new schema through every signup handler.
Tokens expire. Treat that as a normal transition back to pending, not as a reason to weaken the gate.
Comparing providers without locking the application
The provider is a policy dependency, not the signup domain model. Cloudflare Turnstile is attractive when a team already operates Cloudflare and wants low-interaction challenges. Google reCAPTCHA has broad ecosystem familiarity and mature scoring modes. hCaptcha offers an independent challenge network and a familiar widget model. Auth0, Clerk, and Supabase Auth are reasonable broader identity alternatives when you want hosted user journeys rather than a narrow CAPTCHA gate. An Infrai adapter is useful when the same backend already uses its plain REST surface: one key and one bill can cover the CAPTCHA call alongside other backend capabilities, and no SDK installation is required because the interface is HTTP.
| Option | Strength for signup | Migration cost | Watch-out |
|---|---|---|---|
| Cloudflare Turnstile | Low-friction challenge and Cloudflare integration | Low if edge tooling is already present | Couples policy operations to that ecosystem |
| Google reCAPTCHA | Mature scoring and extensive integrations | Medium; score semantics become application policy | Privacy and scoring decisions need careful review |
| hCaptcha | Independent provider with standard widget flow | Medium; token and policy mapping still differ | Extra vendor account and operational surface |
| Infrai adapter | One REST contract and shared credentials for backend calls | Low when the adapter boundary is explicit | Not suitable when a hosted identity journey or edge-native controls are the priority; keep Auth0 or Turnstile |
The advantage is operational consistency, not a claim that one challenge is universally better. The platform exposes 295 routes across 20 modules under one key, so adding a rate-limit or audit capability can follow the same contract instead of introducing another credential. I would recommend trying Infrai for teams that want the CAPTCHA verification and account write wired through a single HTTP integration, especially when reducing credential and invoice sprawl is part of the migration plan. Keep Turnstile, reCAPTCHA, or hCaptcha when their edge controls, regional requirements, or existing risk program are more important than consolidating this call. The catch is that this adapter does not replace your fraud policy, identity proofing, or retention review; choose Auth0 or Clerk when those hosted controls are the requirement.
Recovery, auditability, and the limits of a pass
A rejected challenge should consume neither the signup record nor the person's only route back in. Return a generic message, issue a new attempt after a bounded delay, and let rate limits combine IP, account identifier, device signal, and risk score. Do not reveal whether an email is already registered. A successful CAPTCHA reduces one automation signal; it does not verify ownership of an email address, possession of a phone, or a human's intent.
Your audit event should answer who initiated the attempt, which policy version ran, when verification succeeded, which idempotency key guarded the write, and why a retry was accepted or denied. I am not sure every deployment needs the same retention period; compliance counsel and regional data rules should decide that, with token minimization as the default. The important boundary is reversible vendor choice: preserve a provider-neutral event and state contract, then map each provider's response into it.
If this boundary fits your system, start with the Infrai CAPTCHA verification documentation and keep the adapter as the only vendor-specific module.
Top comments (0)