TL;DR
Use risk-based friction for ticketing bot defense: let low-risk phone-code logins proceed, require CAPTCHA before sending an OTP when several independent signals turn suspicious, and block requests that cross a clearly abusive boundary. Universal CAPTCHA is the narrower choice for a short, exceptional sale when abuse pressure is high and the team cannot operate a scoring policy safely.
That decision has a catch. A risk engine creates policy, monitoring, and appeal work, while a universal challenge taxes every buyer and still doesn't replace server-side throttling. The useful comparison is therefore not "CAPTCHA or nothing." It is universal friction versus selective friction, with rate limits and authorization checks underneath both.
Start with the bill and the data you will retain
For phone one-time-code login, model the expensive term before discussing challenge widgets. The cost equation is straightforward: OTP sends = login attempts × send eligibility rate. CAPTCHA can change the eligibility rate; moving a box around the page cannot change the unit cost of a delivered message. Engineering time, challenge evaluations, support contacts, and abandoned purchases belong in the model too, but OTP sends are the term most directly exposed to automated retries.
Consider a planning example, not a benchmark. Suppose a ticket release receives 1,000,000 login attempts. If 12% enter a suspicious tier and a challenge stops 75% of that tier before the send endpoint, 910,000 requests remain eligible for an OTP: 1,000,000 × (88% + 12% × 25%). Those percentages are assumptions. Replace them with observed funnel counts before using the result for capacity or budget decisions; I'm not sure any borrowed threshold can represent your audience, traffic mix, or accessibility needs.
The same exercise exposes the retention decision. Keep the minimum event fields needed to explain a score and investigate abuse: a pseudonymous account or session key, coarse network and device signals, rule outcomes, challenge outcome, OTP-send decision, and timestamps governed by a documented retention window. Deliberately stop keeping raw challenge payloads and long-lived collections of unrelated device attributes once their operational purpose expires. The cost is less historical detail during a later dispute. The benefit is a smaller pool of authentication and behavioral data to govern.
Short-lived evidence is still evidence.
How should ticketing bot defense place CAPTCHA and risk-based friction?
Place the challenge at the transition that consumes a scarce or abuse-sensitive resource: immediately before the server accepts an OTP send for a suspicious request. Do not put it only on page load. A bot can call the send endpoint directly unless the backend verifies a single-use, short-lived challenge result and binds it to the same login transaction.
There are three distinct threats worth separating. Inventory scraping and page automation may never request a login code. OTP pumping repeatedly triggers message delivery. Account takeover uses a valid or intercepted credential to obtain a session and then attempts a sensitive action. A challenge before OTP send can raise the work factor for the second threat, but it doesn't solve the first or third by itself. Protect inventory APIs separately, and require fresh authorization for security-sensitive account changes or high-risk purchase transitions. OWASP's Authentication Cheat Sheet describes CAPTCHA as defense in depth, advises applying stronger controls based on context, and warns that login throttling should be associated with the account rather than relying only on source IP.
A simple policy can stay readable:
| Tier | Example signal combination | Action before OTP send | Session consequence |
|---|---|---|---|
| Low | Expected request pattern and no active velocity rule | Send the code | Normal session policy |
| Elevated | One weak anomaly or a recent failed attempt | Require CAPTCHA, then send | Record the verified transaction |
| High | Multiple independent anomalies or a crossed abuse limit | Deny or cool down | No session is created |
Don't treat any single signal as identity. IP addresses can be shared, device attributes can change, and a human can retry after a delayed message. Combine weak signals, cap their influence, and make the final action explainable. For a buyer who solves the challenge, the proof should authorize one OTP send for one transaction; it should not become a reusable pass for later attempts.
Make the send path atomic and the session boundary explicit
The backend owns the decision. A browser can collect a challenge result and send it, but it cannot decide that the result is valid, that the transaction is still current, or that another code may be issued. Evaluate rate limits, verify the challenge when required, consume its proof, and reserve the OTP send in one controlled path so simultaneous requests cannot each pass a stale counter.
This Python sketch shows the contract rather than a vendor integration:
from dataclasses import dataclass
from enum import Enum
class Action(Enum):
SEND = "send"
CHALLENGE = "challenge"
DENY = "deny"
@dataclass(frozen=True)
class LoginAssessment:
transaction_id: str
action: Action
policy_version: str
expires_at: int
def request_phone_code(request, risk_engine, challenge_verifier, otp_service):
assessment = risk_engine.assess(request)
if assessment.action is Action.DENY:
return {"status": "not_eligible"}
if assessment.action is Action.CHALLENGE:
challenge_verifier.consume_once(
proof=request.challenge_proof,
transaction_id=assessment.transaction_id,
expires_at=assessment.expires_at,
)
otp_service.reserve_and_send_once(
transaction_id=assessment.transaction_id,
phone=request.normalized_phone,
)
return {"status": "code_sent"}
Keep authentication responses consistent enough that they don't disclose whether a phone number has an account. OWASP recommends generic authentication error messages because differences in text, status behavior, or timing can create a discrepancy factor for account enumeration. Internally, preserve specific reason codes for operators; externally, expose a stable response and a support path.
After code verification, rotate into a new authenticated session rather than upgrading an attacker-chosen session identifier. Bind authorization to server-side state, set a deliberate lifetime, and ask for reauthentication before sensitive changes. CAPTCHA success is an anti-automation signal. It isn't proof that the buyer controls the phone, and phone control alone may be insufficient for changing recovery details or transferring valuable tickets.
Test the policy as a funnel, not a widget
Ship the decision logic in observation mode first when the threat level permits it: calculate the tier, record the action that would have occurred, but leave the buyer flow unchanged. Compare OTP-send eligibility, challenge exposure, completion, resend behavior, successful login, purchase completion, and support contacts by risk tier. This does not establish causation on its own, but it reveals obviously mis-sized rules before they become customer-facing.
Then test the edges. Two concurrent sends for one transaction should produce one reservation. An expired or replayed proof should not authorize a message. A solved challenge bound to transaction A should not work for transaction B. A delayed SMS should not push a legitimate buyer into an endless challenge-resend loop. An accessibility path must reach the same server-side policy rather than bypass it, and operators need a controlled way to resolve false positives without disabling protection for everyone.
Keep a kill switch for each rule and version every policy decision. That's operational plumbing, not an invitation to turn off all controls under pressure. If a threshold starts challenging an implausibly large share of buyers, the team should be able to disable that threshold while account-based throttles, send reservations, and high-confidence deny rules remain active.
Measure by tier.
When is universal CAPTCHA the better trade-off?
Universal CAPTCHA is reasonable when a brief, unusually hostile on-sale creates more risk than the team can classify, the challenge is accessible to the expected audience, and the business accepts the added step for every login. It is also simpler to reason about during an emergency because there are fewer score boundaries. The limitation is bluntness: trusted returning buyers and obvious automation receive the same front-door treatment, while direct API abuse still requires backend verification and throttling.
Risk-based friction is not suitable when the organization cannot monitor score drift, explain denials, protect collected signals, or provide an accessible recovery route. Stick with the simpler universal gate for the narrow event window in that case, then remove it after the heightened condition ends. Conversely, use selective challenges for normal operation when the team can own policy changes and measure their effect on both abuse and purchase completion.
Neither option should be the sole defense. The durable design is layered: transaction-bound challenge verification where risk warrants it, account-aware throttling, atomic OTP reservation, generic public errors, session rotation, and reauthentication for sensitive actions. That gives buyers a low-friction path without pretending that a checkbox can carry the security model.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Top comments (0)