DEV Community

EchoF76
EchoF76

Posted on

Ticketing Login Abuse: 5 CAPTCHA Placement Checks for Risk-Based Friction

Short answer: for ticketing bot defense, use risk-based CAPTCHA placement immediately before a risky user can trigger a scarce or identity-sensitive action, such as sending another phone code or starting account recovery, and let low-risk users continue without it.

The deciding constraint is recovery. A ticketing platform can make the normal phone one-time-code login impressively hard to automate, then quietly leave a cheaper route through “lost phone” or repeated code delivery. CAPTCHA is useful at that boundary, but it isn't proof of identity. It should slow automation while throttling, generic responses, server-side state, and a recovery factor do the security work around it.

That changes the experiment. The simple version challenges everybody at the first login screen and reports a high CAPTCHA completion rate. The useful version asks whether a challenge reduces abusive code sends and recovery attempts without blocking legitimate buyers during a sale. Those are different objectives — and only the second one survives the trip from a notebook into production.

1. How should ticketing bot defense place CAPTCHA for risk-based friction?

Start with action value, not page layout. Viewing an event or typing a phone number does not consume much on its own. Sending a one-time code consumes a limited delivery action; resending it can amplify that cost; opening recovery can lead toward account control; reserving inventory affects other buyers. The challenge belongs just before the server accepts one of those consequential transitions, after it has enough context to assess the attempt.

That means the first phone-code request can pass without a visible challenge when its signals are ordinary. Escalate before a resend burst, a request from a new device paired with recent failed attempts, or entry into recovery after login failures. Keep the decision on the server. A client-side flag such as show_captcha=true is presentation state, not authorization, and a caller must not be able to skip the protected transition by omitting the UI step.

Don't put it on the landing page.

A blanket entrance challenge has weak targeting: it spends customer patience before the application knows which action the visitor wants, and it does nothing by itself to bind a solved challenge to the later phone-code request. At the other extreme, challenging after the code has already been sent is too late for that delivery action. The useful placement is narrow and transactional: evaluate risk, require a challenge if needed, verify its server-side result, then consume a single authorization to perform the protected action.

The same rule applies to checkout, but don't reuse one solved challenge indefinitely. Bind the result to a session, an intended action, and a short validity window chosen from your own threat model. Mark it consumed when the action succeeds. Exact expiry and retry limits need local evidence; I'm not sure there is one defensible number for both a quiet weekday event and a high-demand onsale. Traffic shape, support load, delivery latency, and attacker adaptation would resolve that choice better than a copied constant.

Keep it narrow.

2. Treat phone login and account recovery as one attack surface

The OWASP Authentication Cheat Sheet says account recovery should not be weaker than normal authentication. That is the central design check for a phone-code flow. If a buyer loses the phone number, CAPTCHA can filter automated recovery submissions, but it cannot establish that the person owns the ticketing account. Recovery still needs a separately justified identity signal and a clearly modeled state transition.

Map the paths on one sheet: initial code request, code verification, resend, number change, lost-phone recovery, session reauthentication, and any support-assisted reset. For each transition, write down what the caller must already possess, what rate limit applies, what event is logged, and which response is visible. OWASP recommends generic authentication and recovery responses to reduce account enumeration, so “account not found” should not become the fast branch that teaches an attacker which phone numbers are registered. Comparable outward responses matter even when internal handling differs.

This is where the easy design usually fails. Teams protect /login, consider recovery a support feature, and evaluate them independently. An attacker doesn't respect that organization chart. If recovery allows more retries, reveals account existence, or grants a number change on evidence weaker than the regular flow, the CAPTCHA placement on login is mostly theater. Model both paths in the same state machine and run the same abuse review against them.

There is a product trade-off here. A buyer who legitimately changed numbers needs a route back, while a fast number-change path can be attractive to an account-takeover attempt. The defensible response is not endless challenge stacking. It is an explicit recovery policy with stronger evidence for higher-impact changes, neutral user-facing messages, throttled attempts, and a review path for cases the automated policy cannot settle.

3. Turn risk signals into a small, testable decision

Avoid a mysterious “AI risk” value that nobody can reproduce. A first production policy can be a short deterministic function built from signals the platform can collect and govern: recent failed verification attempts, request rate, device familiarity, and whether the flow has crossed into recovery. The point of the score is not mathematical sophistication. It is to make challenge placement reviewable, replayable in an eval harness, and cheap enough to run before each protected action.

Here is a focused Python example. The numbers are illustrative policy settings, not universal security thresholds; tune them against labeled traffic and document every change.

from dataclasses import dataclass
from enum import Enum


class Decision(str, Enum):
    ALLOW = "allow"
    CHALLENGE = "challenge"
    LIMIT = "limit"


@dataclass(frozen=True)
class Attempt:
    failed_codes_15m: int
    sends_10m: int
    familiar_device: bool
    recovery_flow: bool


def decide(attempt: Attempt) -> Decision:
    # Hard limits protect the action even when a challenge has been solved.
    if attempt.sends_10m >= 5 or attempt.failed_codes_15m >= 8:
        return Decision.LIMIT

    score = 0
    score += min(attempt.failed_codes_15m, 4)
    score += 3 if attempt.sends_10m >= 3 else 0
    score += 2 if not attempt.familiar_device else 0
    score += 2 if attempt.recovery_flow else 0

    return Decision.CHALLENGE if score >= 4 else Decision.ALLOW
Enter fullscreen mode Exit fullscreen mode

Notice the hard limit. CAPTCHA cannot replace login throttling, a point OWASP makes directly in its automated-attack guidance. If a solved challenge always resets the send counter, a human-assisted or challenge-solving attacker can still drive code delivery. The policy therefore has three outcomes: allow ordinary attempts, challenge suspicious attempts, and limit attempts that have crossed an operational boundary. “Limit” should use a generic outward response and must not disclose whether the phone number belongs to an account.

Keep the feature inputs small at first. Device familiarity can be useful, but it deserves a retention policy and a definition precise enough to test. Network reputation can help, but shared mobile networks can make it noisy. Behavioral features may add signal, but they also add collection, latency, and debugging cost. Every new feature spends an operational budget — much like another model call in an agent pipeline — so require an eval gain before promoting it into the login path.

4. Evaluate placement with outcomes, not challenge completions

The experiment should compare policy variants at the action boundary. One variant might challenge only risky resends and recovery starts; another might challenge risky first sends as well. Log the policy version, decision, coarse reason codes, protected action, challenge outcome, throttle outcome, and eventual account outcome without storing the one-time code or unnecessary personal data. A notebook can then replay candidate policies over appropriately protected historical features before a staged deployment.

Completion rate is diagnostic, not the goal.

Use a compact evaluation table so security, growth, support, and accessibility reviewers are discussing the same outcomes:

Question Measure Failure it exposes
Did automation lose leverage? Abusive protected actions per attempt cohort A challenge shown too late or detached from the action
Did legitimate buyers get through? Successful login and recovery by risk band Excess friction on ordinary traffic
Did pressure move elsewhere? Resend, recovery, and support-reset attempt mix Displacement into a weaker path
Can the team explain decisions? Decision counts by policy version and reason code An opaque or drifting policy
Is the control usable? Abandonment and support contacts for challenged cohorts Accessibility or interaction costs

Labels will be imperfect. Some abusive activity is known only after later investigation, while some abandoned sessions have innocent causes. Keep that uncertainty visible: report results by cohort and label maturity, preserve the original policy version, and resist turning an early proxy into a claim of attacks prevented. A risk threshold should move only when the eval shows why, what segment bears the extra challenge rate, and whether the recovery path changed at the same time.

Measure displacement.

Rollout also needs a kill switch for the challenge requirement that does not disable throttling or recovery controls. Start with decision logging, inspect the would-challenge cohort, then expose friction to a limited slice while watching the protected actions. The architecture should let operators tighten a single action — for example, code resend — without placing a universal wall in front of every buyer.

5. Know when CAPTCHA is the wrong control

CAPTCHA is not suitable as the sole defense for inventory reservation, payment, phone-number changes, or account recovery. Those actions need controls matched to their impact: server-side rate limits, transaction state, reauthentication, recovery evidence, and monitoring. Stick with no visible CAPTCHA for low-risk attempts when the eval shows that background limits and risk signals are sufficient; adding friction there buys little information and can exclude real users.

The catch is accessibility and failure handling. A challenge mechanism must offer a usable path for people who cannot complete its primary interaction, and the application needs a neutral way to retry without granting unlimited protected actions. If that path cannot be supported, choose a different step-up control for the affected flow rather than making CAPTCHA universal. The right control may also differ between login and recovery because the consequence of a false allow is different.

Before copying this five-check policy, measure four things in your own system: how often code sends and resends are abused, which signals are available before each action, how legitimate completion changes by risk band, and whether attackers shift toward recovery or support. Then set thresholds with an eval harness, version the policy, and revisit it after each material flow change.

That is the practical decision rule: challenge at the last responsible moment before a consequential action, keep a harder server-side limit behind it, and never let account recovery become the unmeasured side door.

References

Top comments (0)