Verifying a CAPTCHA token server-side before trusting a form

A widget without a server-side verify is decoration. The call, the four response fields worth checking, and the hostname check most integrations skip.

A CAPTCHA widget in your form proves nothing on its own — the protection is the server-side check, and skipping it means an attacker posts your form directly with no token at all. On Infrai that check is POST /v1/captcha/verify with the widget_record_id and the token your page collected, and the response carries success, a score, the hostname it was solved on and the action it claimed.

Check all four. Most integrations look at success and stop, which leaves two of the three useful signals on the table.

The call

curl -sS -X POST "https://api.infrai.cc/v1/captcha/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "widget_record_id": "cwg_2fVc8nRqLmT4xBzY",
    "token": "P1_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
    "ip": "203.0.113.24",
    "action": "signup",
    "expected_hostname": "app.example.com",
    "score_threshold": 0.5
  }'
{
  "ok": true,
  "data": {
    "success": true,
    "score": 0.9,
    "hostname": "app.example.com",
    "action": "signup",
    "challenge_ts": "2026-09-21T03:41:12Z",
    "vendor": "hcaptcha",
    "reasons": []
  }
}

expected_hostname is the field to always send. Without it, a token solved on an attacker’s page — where they embedded your sitekey — verifies happily against your backend. With it, the check fails because the hostname doesn’t match, and that’s the difference between a CAPTCHA and a formality.

score_threshold lets the platform apply the cutoff rather than you forgetting to compare. action binds the token to the operation it was solved for, so a token collected on a low-value form can’t be replayed against your signup.

Tokens are single-use and short-lived

Two properties every CAPTCHA vendor shares and every integration eventually trips over.

A token can be verified once. If your handler verifies, then hits a validation error, then re-verifies on the retry, the second attempt fails and the user sees a confusing “please complete the CAPTCHA again” after a typo in their email address. Verify once, and keep the result for the rest of the request.

Tokens also expire in a couple of minutes. A form the user leaves open for ten minutes has a dead token, so your client needs to re-solve rather than submitting stale — which is why the widget and the submit button should be close together in the flow.

A handler that checks everything

import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
WIDGET = os.environ["CAPTCHA_WIDGET_ID"]
EXPECTED_HOST = os.environ.get("APP_HOST", "app.example.com")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


class CaptchaRejected(Exception):
    """Raised when the token is missing, reused, low-scoring or from elsewhere."""


def verify(token: str, client_ip: str, action: str, min_score: float = 0.5) -> dict:
    if not token:
        raise CaptchaRejected("no token supplied")

    resp = SESSION.post(
        f"{API}/v1/captcha/verify",
        json={"widget_record_id": WIDGET, "token": token, "ip": client_ip,
              "action": action, "expected_hostname": EXPECTED_HOST,
              "score_threshold": min_score},
        timeout=15,
    )
    body = resp.json()
    if not body.get("ok"):
        raise CaptchaRejected(body.get("error", {}).get("code", "verify_failed"))

    data = body["data"]
    if not data.get("success"):
        raise CaptchaRejected(f"not solved: {data.get('reasons')}")
    # Belt and braces: the platform applied score_threshold, but a local check
    # means a config change on one side can't silently lower your bar.
    if (data.get("score") is not None) and data["score"] < min_score:
        raise CaptchaRejected(f"score {data['score']} below {min_score}")
    if data.get("hostname") and data["hostname"] != EXPECTED_HOST:
        raise CaptchaRejected(f"solved on {data['hostname']}, not {EXPECTED_HOST}")
    if data.get("action") and data["action"] != action:
        raise CaptchaRejected(f"token was for {data['action']}, not {action}")
    return data


def handle_signup(form: dict, client_ip: str) -> dict:
    verify(form.get("captcha_token", ""), client_ip, action="signup")
    # Only now does anything expensive happen.
    return {"created": True, "email": form["email"]}


if __name__ == "__main__":
    try:
        print(handle_signup({"email": "ada@example.com", "captcha_token": "invalid"}, "203.0.113.24"))
    except CaptchaRejected as exc:
        print(f"rejected: {exc}")

Verifying before anything expensive happens is the point of the whole exercise. A handler that creates the user, sends the email and then checks the CAPTCHA has spent money on a bot.

What to do with a failure

OutcomeResponse
No token at all400, and log it — this is a direct post
success: falseask the user to retry the challenge
Score below thresholdtreat as suspicious: extra friction, not a hard block
Hostname mismatchreject and alert; someone embedded your sitekey
Action mismatchreject; a token is being replayed

A low score is the interesting case. It means “probably automated”, not “definitely”, so a hard block will occasionally refuse a real person on a VPN with a hardened browser. Adding an email verification step for low scores keeps them out of your product without locking out the honest ones.

Fail closed, but decide deliberately

If the verify call itself can’t complete, you have a choice with no free answer. Failing closed blocks signups during a blip; failing open lets bots through. For a signup form, closed is right — a few minutes of refused registrations beats a flood of fake accounts, and POST /v1/errors/capture on the same key gives you the alert to act on.

Whatever you pick, make it explicit in the code rather than an accident of exception handling.

Limitations

Widget provisioning currently reports hcaptcha as its ready vendor while verification accepts both hcaptcha and tencent_captcha, so the vendor you verify against depends on the widget you created — read GET /v1/discovery/captcha.verify for the live list rather than assuming. There’s also no bot-management surface here: no per-IP reputation, no device fingerprinting, no challenge customisation beyond the widget mode. If abuse is a core product risk rather than a signup nuisance, a dedicated bot-management product will do far more than a verify endpoint, and Turnstile or reCAPTCHA Enterprise going direct gets you their own analytics dashboards.

What’s on the same credential is the rest of the signup: the POST /v1/auth/email/send_code that follows a passing check, the POST /v1/errors/capture that records a hostname mismatch, and one GET /v1/account/usage covering both. Verification is billed per verify at a rate live in GET /v1/discovery/captcha.verify (verified 2026-09-21, and small enough that the check is never the expensive part of a signup) — those rates drift downward as vendor contracts improve.

References

Browse more captcha developer guides