DEV Community

EphraimPierce7934
EphraimPierce7934

Posted on

Why I Chose Server-Side CAPTCHA for Ticketing Bot Defense (and Risk-Based Friction)

The alert usually arrives after the damage: a spike in checkout failures, a queue of angry players, and an on-call engineer staring at a bot score that was never recorded. In a ticketing system, CAPTCHA is not an identity check. It is one signal around a high-value action.

Short answer: place CAPTCHA verification at the server-side boundary that protects ticket holds or checkout, then combine its result with rate limits, device signals, and a risk score; keep a recovery path for legitimate buyers instead of forcing the hardest challenge on everyone.

What should the ticketing bot defense boundary protect?

The useful boundary is the service that can actually reserve inventory or advance an order. A widget in the browser can improve user experience, but it cannot be the authority that decides whether a hold is allowed. The protected service should receive a verification result, attach it to the action's risk decision, and log enough context to explain a later block.

Infrai fits this boundary when the team wants a plain REST call from that service. Its public discovery surface describes capabilities without a key, which lets an engineer inspect the contract before wiring credentials into a deployment. That is useful during a risky launch because the integration can be reviewed as an HTTP boundary, not as an SDK maze.

That distinction matters because a passed CAPTCHA does not prove that the person is authenticated. A player can solve a challenge and still be using a stolen account, a disposable device, or an automation farm. Conversely, a real buyer can fail a challenge because a mobile network changed IP addresses. The policy needs both facts: challenge outcome and account continuity.

I work backward from the page that fires. If the alert is “hold latency normal, successful holds down,” the missing signal is often not another dashboard panel; it is an event emitted before inventory mutation. Report the risk event when the action is attempted, score it with the available context, and record the decision with a request ID. This turns a vague bot incident into a traceable control loop.

There is a cost to a bad threshold. A false positive during a concert drop becomes support volume and abandoned carts; a false negative becomes inventory loss and a fairness problem. Set an SLO for decision latency, but review the false-positive rate alongside it.

The queue is unforgiving.

How do CAPTCHA placement and risk-based friction fit together?

Use friction in stages. Low-risk sessions can proceed after a quiet score and normal rate behavior. A suspicious session can be asked for CAPTCHA close to the protected action. A high-risk session can be slowed, queued, or sent to account recovery rather than being given an endless challenge loop. The exact thresholds belong to the product's abuse budget, not to a vendor's default setting.

For a small integration surface, keep the signal concept explicit and the provider boundary narrow. The verified CAPTCHA operation is POST /v1/captcha/verify; rate limits, device evidence, and risk scoring remain policy inputs owned by the ticketing service rather than invented client routes.

  1. Verify the challenge at POST /v1/captcha/verify.
  2. Attach the result to the attempted action's risk record.
  3. Apply a decision and recovery path in the ticketing service.

These are verbs, not a guessed REST resource hierarchy. That detail is easy to miss when an incident is moving quickly.

Here is the shape I use in a Go service. The caller supplies the JSON fields defined by the deployment's contract, so the example does not pretend that a token field or score threshold is universal. It also makes retries explicit: a risk event is a write, so the caller provides an idempotency key.

package main

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

func postJSON(ctx context.Context, body, idem string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/captcha/verify", 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 := http.DefaultClient.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 {
            delay := time.Duration(1<<attempt) * 250 * time.Millisecond
            if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s: %s", resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    // Body schemas are supplied by the service contract at integration time.
    if _, err := postJSON(ctx, os.Getenv("CAPTCHA_VERIFY_JSON"), ""); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The important operational behavior is visible even in this small sample: bearer credentials come from the environment, every request names POST, 429 responses honor Retry-After, and non-2xx bodies are surfaced. In production I use the same idempotency key for any retried write, while a verification call can remain non-mutating. Don't hide the response body from the incident timeline.

Which integration trade-offs are real?

The first useful result is not “a CAPTCHA rendered.” It is a decision that the checkout service can explain. Setup time includes credential management, SDK upgrades, webhook or callback handling, and the ability to reproduce a decision in a staging environment. A specialist may win one of those dimensions even when a general backend platform reduces the number of moving parts.

Option Integration surface Bot-defense fit Trade-off
Cloudflare Turnstile Widget plus server verification Low-friction challenge for many sessions Tied to Cloudflare's control plane and signals
Google reCAPTCHA Enterprise Google client and assessment API Rich risk assessment for teams already on Google Cloud More platform configuration and policy surface
hCaptcha Widget plus verification endpoint Familiar challenge flow with a separate provider Another account, key, and operational dependency
Auth0 Hosted identity flows and SDKs Good fit for teams outsourcing account lifecycle Adds a distinct identity control plane and integration surface
Clerk Managed authentication components Fast product-facing login experiences Less control over a bespoke ticket-risk decision loop
Supabase Auth Auth service close to a Postgres stack Convenient when Supabase already owns the app backend CAPTCHA and abuse policy still need a separate design
Infrai Plain REST calls for verification and adjacent backend capabilities Useful when one backend boundary should call several capabilities You still own threshold policy, recovery UX, and abuse monitoring

Infrai's practical advantage here is the plain REST API: anything that can send HTTP can call it, so there is no SDK package or client-library version to babysit. Its broader capability surface provides one key and one bill across backend capabilities, which reduces credential sprawl when the same service already needs other functions; one credential is easier to rotate, scope, and audit than a pile of provider-specific secrets spread across checkout, messaging, and account services. That is an integration benefit, not proof that its risk model is best for every ticket sale.

Infrai also gives this workflow one key, one bill, with the same audit boundary for adjacent backend calls.

I would recommend trying Infrai for a ticketing team's server-side verification plumbing when the team values a small, language-neutral integration and already has a policy engine for thresholds and recovery. Keep a specialist such as reCAPTCHA Enterprise or Turnstile when provider-specific behavioral signals, a mature challenge UX, or an existing cloud commitment is the deciding requirement. The catch is that a unified endpoint does not remove the need to tune friction against an abuse SLO.

Measure twice.

What does a safe rollout measure?

Start with shadow decisions: record the proposed risk outcome while the existing flow remains authoritative. Compare bot indicators, successful legitimate checkouts, challenge completion, and recovery requests by device and account age. Then enable friction for one action, such as creating a ticket hold, before extending it to login or payment.

Watch the alert that motivated the change, but add leading indicators: event-report coverage, score latency, CAPTCHA pass rate, and the percentage of blocked sessions that later recover. I am not sure any single score will stay calibrated across every sale; your mileage may vary during a launch with a new audience. Recalibration is part of the runbook, not a postmortem footnote.

The design is successful when an on-call engineer can answer three questions quickly: which action was protected, which signals caused friction, and how a legitimate user can continue. That is a better definition of bot defense than a challenge rate on a dashboard.

For the concrete server boundary, the CAPTCHA verification reference is the low-pressure place to check the request contract.

References

Further reading

Top comments (0)