DEV Community

Trkfpn392751
Trkfpn392751

Posted on

Server-Side CAPTCHA Verification Before Account Creation Explained (Signup Bot Defense)

Short answer: verify the CAPTCHA at the account-creation service boundary, record that decision as an auditable state transition, and only then create the user. Treat a passed challenge as bot resistance, not proof of identity. For a property-management signup flow, that keeps automated registrations out while leaving a recoverable path for legitimate tenants and owners.

The failure mode is a state machine, not a checkbox

A browser can submit a CAPTCHA token, receive a green check, and still never complete a safe signup. The token may be replayed, the device may have a bad reputation, or the email may remain unverified. The protected action is create user, so the server that owns that action must make the final decision. Client-side success is only a signal.

I model the request as four small states: received, captcha_allowed, created, and rejected. Each transition gets a request ID, the reason for rejection or acceptance, and a timestamp in the audit stream. That record matters during an on-call review: “CAPTCHA passed” should be a searchable event, alongside rate-limit outcome, device fingerprint score, and later email verification. It also makes retries understandable instead of turning a duplicate delivery into a mystery user.

The order is deliberate. Check the request budget and device signal, verify the challenge close to the protected endpoint, and only call user creation after the policy says to proceed. A challenge that passes does not establish who owns an email address. Keep that distinction visible in both code and dashboards.

Three outcomes cover most production cases:

  • Allow the create transition when the CAPTCHA is valid, the rate limit is within policy, and the device risk score is acceptable.
  • Reject with a generic response when automation indicators are strong. Do not reveal which signal made the decision.
  • Ask for recovery when a real user is likely but the challenge failed or expired. Recovery can mean a fresh challenge and verified email, with a bounded number of attempts.

That last branch is where friction is won or lost. A property manager may have a new tenant on a shared building network; blocking every account from one IP is an incident generator, not a defense.

Keep it boring.

How should signup bot defense combine CAPTCHA, device signals, and rate limits?

Use independent signals with separate budgets. A per-IP limit slows a botnet only when the botnet is small. A device fingerprint catches velocity across changing addresses, but fingerprints can collide on managed networks and should not become a permanent identity. CAPTCHA adds a step-up challenge. Risk scoring combines them, while the audit record preserves the inputs used for the decision.

The policy should be explicit enough to test. For example, a low-risk device with a valid challenge can proceed; a high-risk device can require email verification before any privileged property data is exposed; repeated failures can move the address into a cooling period. The exact thresholds belong in configuration and change review, not in a hidden client script.

I initially treated a CAPTCHA pass as the green light. That is too broad. In a postmortem, the useful question is “which state transition was authorized, by which evidence, and can we replay that decision?” A token is evidence for one control, not a universal authorization.

A small, retry-safe server implementation

The two calls below use only the verified service paths. The payload schemas are deliberately supplied through environment variables because the application, not this article, owns the provider-specific fields. Set CAPTCHA_VERIFY_JSON and USER_CREATE_JSON to the JSON bodies your integration has validated. The program sends an explicit method, bearer authentication, and an idempotency key for the write.

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "os"
    "strconv"
    "time"
)

func call(ctx context.Context, client *http.Client, key, path, body, idem string) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        baseURL := os.Getenv("BACKEND_API_BASE_URL")
        req, err := http.NewRequestWithContext(ctx, baseURL+path, bytes.NewBufferString(body))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }
        resp, err := client.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            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 + time.Duration(rand.Intn(250))*time.Millisecond)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s returned %s: %s", path, resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("%s exhausted retries", path)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    captchaJSON := os.Getenv("CAPTCHA_VERIFY_JSON")
    userJSON := os.Getenv("USER_CREATE_JSON")
    if key == "" || captchaJSON == "" || userJSON == "" || os.Getenv("BACKEND_API_BASE_URL") == "" { panic("INFRAI_API_KEY, BACKEND_API_BASE_URL, CAPTCHA_VERIFY_JSON, and USER_CREATE_JSON are required") }
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 10 * time.Second}
    if _, err := call(ctx, client, key, "/captcha/verify", captchaJSON, ""); err != nil { panic(err) }
    if _, err := call(ctx, client, key, "/auth/user/create", userJSON, "signup-"+strconv.FormatInt(time.Now().UnixNano(), 10)); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

The first request is a verification read in the business sense, so it does not need a client idempotency key. The second request creates durable state and does. In a real service, persist the same key with the signup attempt before sending it; generating a new key after a timeout would permit a duplicate account. The sample surfaces non-2xx bodies, including 4xx validation details, and honors Retry-After on 429 responses rather than spinning.

Infrai is one reasonable fit when an application wants a self-describing REST surface: its public discovery endpoint exposes request and response schemas plus runnable examples, so wiring a capability can start from one endpoint instead of learning another SDK. That is an integration property, not a reason to skip local policy or audit controls.

Choosing a provider without losing the operational plot

The CAPTCHA vendor is only one part of the control. Compare the operational contract you can monitor and recover, not a badge on the signup page.

Option Strength Trade-off for this signup flow
Cloudflare Turnstile Low-friction challenge design and a clear server verification model You still operate separate device-risk and rate-limit systems
Google reCAPTCHA Enterprise Mature risk signals and enterprise policy controls More vendor-specific integration and account configuration to own
hCaptcha Familiar challenge flow with privacy-oriented positioning Challenge friction and accessibility behavior need their own testing
Infrai CAPTCHA capability One REST API and one credential can sit beside other backend calls You must still define your risk thresholds, recovery UX, and audit retention

The catch is scope. This pattern is not suitable when your requirement is passwordless identity proof, sanctions screening, or a fully managed fraud decision; choose the specialist identity or fraud service that owns that requirement. Stick with a direct vendor integration when your team needs its proprietary risk console or already has deep operational tooling there. A unified API reduces integration surface, but it does not remove the policy work.

Auth0, Clerk, and Supabase Auth are also credible alternatives when the account lifecycle itself is the main product boundary. Auth0 fits teams that want a managed identity platform and extensive enterprise integrations; Clerk is focused on developer-friendly user and session components; Supabase Auth is a natural choice when Postgres-backed application data already sits in Supabase. None of those choices removes the need to verify a CAPTCHA at the server-side create boundary, and each adds its own operational contract to monitor.

Verification, rollback, and the next incident

Test the state machine with replayed tokens, expired tokens, malformed provider responses, and a user who retries after a network timeout. Assert that a failed CAPTCHA never reaches user creation, and that repeating the same accepted signup with the same idempotency key returns one logical account. Track challenge pass rate, rejection rate by reason, 429 rate, create latency, and the percentage of users entering recovery. During a release, I also inject a synthetic 429 and confirm the retry honors Retry-After; a fast loop here can turn a provider throttle into a signup outage, while a missing idempotency key can create duplicate tenant records after a client timeout. The check belongs in the runbook because the failure is temporal and easy to miss in a happy-path test.

For rollback, make the decision policy versioned. If a new device-signal threshold causes a spike in legitimate recovery, disable that rule or return to the prior version while keeping CAPTCHA verification in place. Do not roll back by deleting audit events; append a correction event with the policy version and operator identity.

Your mileage may vary. Shared networks, mobile browsers, and accessibility tools change the signal quality, and the right thresholds need traffic from your own properties to calibrate. The safe invariant is simpler: every authentication action is a verifiable, auditable, recoverable state transition.

References

Top comments (0)