Where the CAPTCHA check belongs in signup and login
Put it in front of the calls that cost money or send messages, not in front of everything. A route-by-route placement, and the order inside the handler.
CAPTCHA placement is an economics question. A bot costs you money when it makes you send an email, mint a session, write a record or call a paid vendor — so the check belongs immediately in front of those, and nowhere else. On Infrai the relevant pairs are obvious once you look: POST /v1/captcha/verify before POST /v1/auth/email/send_code, before POST /v1/auth/phone/send_code, and before POST /v1/auth/password/reset_request.
Put it on every endpoint and you’ve added friction to your product. Put it on none of the send routes and you’ve funded someone else’s SMS bill.
Route by route
| Your endpoint | Behind it | CAPTCHA? |
|---|---|---|
| Request an email code | auth.email.send_code | yes — sends a message |
| Request an SMS code | auth.phone.send_code | yes — spends real money |
| Password reset request | auth.password.reset_request | yes — sends a message |
| Verify a code | auth.email.verify | no — the code is the proof |
| Password login | auth.session.create | after failures, not on the first try |
| Session refresh | auth.session.refresh | no — the refresh token is the proof |
| Username availability | your own read | no — rate-limit it instead |
Two rows explain the whole table. Anything with a one-time code already in hand doesn’t need a CAPTCHA, because possessing the code is a stronger proof than solving a puzzle. And anything that sends something needs one, because that’s where an attacker converts your endpoint into your expense.
The SMS row is the one to get right first. Email costs a fraction of a cent; SMS costs real money per message, which makes an unprotected phone-OTP endpoint the most directly monetisable target in most products.
Progressive on login
Requiring a CAPTCHA on every login is friction on the most common action in your product, and it doesn’t buy much — credential stuffing is better answered by rate limits and by the platform’s own AUTH_RATE_LIMIT.
Ask for one after failures instead:
import os
import time
from collections import defaultdict
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
WIDGET = os.environ["CAPTCHA_WIDGET_ID"]
HOST = os.environ.get("APP_HOST", "app.example.com")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
FAILURES: dict[str, list[float]] = defaultdict(list)
WINDOW_SECONDS = 900
CAPTCHA_AFTER = 2
def recent_failures(key: str) -> int:
cutoff = time.monotonic() - WINDOW_SECONDS
FAILURES[key] = [t for t in FAILURES[key] if t > cutoff]
return len(FAILURES[key])
def captcha_required(email: str, client_ip: str) -> bool:
"""Require a challenge once this address or address-plus-IP has failed a
couple of times. Most users never see it; a stuffing run sees it immediately."""
return max(recent_failures(email), recent_failures(client_ip)) >= CAPTCHA_AFTER
def verify_captcha(token: str, client_ip: str, action: str) -> None:
resp = SESSION.post(
f"{API}/v1/captcha/verify",
json={"widget_record_id": WIDGET, "token": token, "ip": client_ip,
"action": action, "expected_hostname": HOST, "score_threshold": 0.5},
timeout=15,
)
body = resp.json()
if not body.get("ok") or not body["data"].get("success"):
raise PermissionError("captcha failed")
def login(email: str, password: str, client_ip: str, captcha_token: str | None) -> dict:
if captcha_required(email, client_ip):
if not captcha_token:
return {"status": "captcha_required"}
verify_captcha(captcha_token, client_ip, action="login")
resp = SESSION.post(
f"{API}/v1/auth/session/create",
json={"email": email, "password": password, "method": "password"},
timeout=20,
)
body = resp.json()
if not body.get("ok"):
FAILURES[email].append(time.monotonic())
FAILURES[client_ip].append(time.monotonic())
return {"status": "invalid_credentials"}
return {"status": "ok", "session": body["data"]}
if __name__ == "__main__":
print(login("ada@example.com", "wrong", "203.0.113.24", None))
Returning captcha_required as a state rather than an error is what makes the client able to render the widget and retry cleanly.
Order inside the handler
Verify before anything with a side effect, and after cheap input validation. That ordering matters for a reason people discover the hard way: tokens are single-use, so if you verify first and then reject the request for a malformed email, the user’s retry has a spent token and they see a confusing second challenge.
Validate shape, verify the token, then act.
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"
}'
{
"ok": true,
"data": {
"success": true,
"score": 0.9,
"hostname": "app.example.com",
"action": "signup",
"challenge_ts": "2026-09-21T03:41:12Z",
"vendor": "hcaptcha",
"reasons": []
}
}
Bind action to the operation. A token solved on your newsletter form shouldn’t work against signup, and passing action is how the platform enforces that for you.
Defence in depth, not instead of it
A CAPTCHA is one layer. The others are already on the same key and cost nothing to add.
AUTH_RATE_LIMIT from the auth routes bounds attempts per address without any work from you. PUT /v1/account/budget/set bounds what an abuse campaign can cost even if everything else fails. And POST /v1/errors/capture gives you the signal that something is happening — a spike in failed verifies with a hostname mismatch is somebody testing your sitekey on their own page, which you want to know about the same day.
Limitations
A CAPTCHA stops scripted abuse, not determined abuse: solving services exist, and a motivated attacker with a budget gets through. It also can’t tell you anything about a request that arrives with a valid token from a real human being paid to create fake accounts — that’s a fraud problem rather than a bot problem, and this endpoint isn’t a good fit for it.
There’s no analytics surface here either: no challenge-outcome dashboard, no per-widget conversion impact reporting. reCAPTCHA Enterprise and Turnstile both give you that going direct, and if you need to tune placement against conversion data weekly, their dashboards are the reason to use them.
What’s genuinely easier is that the check and everything it protects are one credential: the verify, the POST /v1/auth/email/send_code behind it, the budget cap that backs it up and one GET /v1/account/usage pricing the lot. Widget management is free; verification is billed per verify at a rate live in GET /v1/discovery/captcha.verify (verified 2026-09-21), drifting downward as vendor contracts improve.