Short answer: For ticketing bot defense, the right CAPTCHA placement is after an initial risk assessment but before any action that creates scarce work or changes recovery state. For a forgot-password flow, that means accepting an identifier, returning a generic response, evaluating abuse signals, and requiring extra friction before sending or regenerating a recovery artifact when risk is high. Keep low-risk recovery usable, and make every state transition auditable and idempotent.
I've been paged by missed scheduled work and duplicate deliveries. That history changes how I look at account recovery: a correct screen is not enough. The queue can redeliver, a user can double-click, and two requests can race. If either request emits another recovery message or silently changes the challenge decision, the authentication control has become an operational incident waiting for an audit.
The invariant is short: one accepted recovery intent may produce at most one live recovery artifact for its idempotency window, while the public response must not reveal whether the account exists. CAPTCHA is one input to that control, not the control itself.
Retries happen.
How should ticketing bot defense place CAPTCHA for risk-based friction?
Place it at a state boundary, not at the top of every form. The first request can collect the account identifier and enough request context to calculate risk. It must respond consistently for known and unknown accounts, as OWASP recommends, because different messages or meaningfully different processing paths can enable account enumeration. A higher-risk request then receives a challenge before the system queues an email, rotates a recovery artifact, or permits another expensive attempt.
This ordering matters during a ticket onsale. A blanket challenge before the identifier is submitted spends human attention on every recovery attempt, including the routine ones, while giving the service little context for a decision. A challenge after an email has already been queued is too late; the attacker has consumed the scarce operation. The useful boundary sits between assessment and side effect.
Use signals that describe the request rather than a permanent label on the person: request velocity for the account and network, recent challenge outcomes, device continuity, session context, and the sensitivity of the next action. Don't let a single signal decide indefinitely. Carrier-grade NAT, corporate gateways, and accessibility tools can make ordinary users look unusual, so the policy needs a path from “suspicious” to “verified” without trapping the user in repeated challenges.
OWASP describes CAPTCHA as a defense-in-depth control and notes that it should be required only after a small number of failed attempts rather than on the first attempt. For password recovery, the same operational principle is useful: rate limits and consistent responses remain in force, then a challenge adds friction when the observed behavior crosses a documented threshold. The exact threshold is local. I'm not sure there is a universal value that survives both a quiet weekday and a major onsale; replaying your own traffic distributions, including false positives, is what resolves that uncertainty.
The recovery ledger is the real control
Treat recovery as a small state machine backed by a ledger. An intent starts as assessed, may become challenge_required, and can advance to dispatch_accepted only once. Store the policy version and a coarse reason code with the transition. Do not store raw challenge answers or dump sensitive request material into logs. The audit question six months later is “why was this transition allowed?”, not “can we reconstruct every browser fingerprint?”
The idempotency key belongs to the recovery intent, not to an individual queue attempt. A retry with the same key returns the prior accepted result; it doesn't mint another token. A second worker delivery observes that dispatch has already been claimed and exits successfully. This is the same discipline used for scheduled jobs: the transport may promise delivery, but the business state decides whether work is still due.
Here is the preventative path in Go. The interfaces are deliberately generic because the transaction boundary is the important part.
package recovery
import (
"context"
"errors"
"time"
)
var ErrChallengeRequired = errors.New("additional verification required")
type Request struct {
AccountLookup string
IdempotencyKey string
ChallengeProof string
ObservedAt time.Time
}
type Assessment struct {
RequireChallenge bool
ReasonCode string
PolicyVersion string
}
type Result struct {
Accepted bool
PublicMessage string
}
type RiskEngine interface {
Assess(context.Context, Request) (Assessment, error)
VerifyChallenge(context.Context, string) error
}
type Ledger interface {
// AcceptIntent atomically returns an existing result or records one new dispatch.
AcceptIntent(context.Context, Request, Assessment) (Result, error)
}
func Start(ctx context.Context, req Request, risk RiskEngine, ledger Ledger) (Result, error) {
assessment, err := risk.Assess(ctx, req)
if err != nil {
return Result{}, err
}
if assessment.RequireChallenge {
if req.ChallengeProof == "" {
return Result{}, ErrChallengeRequired
}
if err := risk.VerifyChallenge(ctx, req.ChallengeProof); err != nil {
return Result{}, ErrChallengeRequired
}
}
result, err := ledger.AcceptIntent(ctx, req, assessment)
if err != nil {
return Result{}, err
}
result.PublicMessage = "If the account is eligible, recovery instructions will be sent."
return result, nil
}
Notice what the handler doesn't do: it doesn't send mail inside the request transaction. The ledger commits an outbox record, and a worker dispatches it. That separation gives retries somewhere safe to land. It also lets the public request finish on a consistent path for existing and nonexistent accounts, rather than using a mail provider call as an accidental account-existence oracle.
Short paths matter.
The audit record should connect the recovery intent, policy version, challenge outcome, dispatch claim, and final delivery status through opaque identifiers. Operators need counters for assessment outcomes, challenge pass rates, deduplicated dispatch attempts, queue age, and recovery completion. Alert on changes in ratios and age, not on every rejected bot request; rejection volume during an onsale can be expected, while a growing queue means legitimate recovery is being delayed.
Compare placements by failure mode
The placement decision becomes clearer when each option is tested against the side effect it protects.
| Placement | What it protects | Operational cost | Use it when |
|---|---|---|---|
| Before identifier submission | The assessment endpoint itself | Friction for every user and little request context | The endpoint is under direct resource exhaustion and lighter controls are insufficient |
| After assessment, before dispatch | Recovery messages, token creation, and queue capacity | A second interaction for elevated-risk requests | Most flows, provided assessment and challenge state share a bounded lifetime |
| After dispatch | Little; the expensive action already happened | User friction without protecting the scarce operation | Avoid for recovery-message abuse |
| At token redemption | Account takeover at the final state change | A legitimate user may face friction after opening email | The redemption event has new risk or a higher assurance requirement |
The middle row is the default, not a law. A high-value account may need stronger verification at redemption even after an earlier challenge. Conversely, a request that already carries a recently verified session may need no CAPTCHA at all. The runbook should say which evidence lowers friction, how long that evidence lasts, and which recovery state transitions still demand fresh verification.
Test races, not just pages. Fire two identical recovery requests concurrently and assert that one dispatch record exists. Redeliver the same queue item and assert that the token is unchanged. Submit known and unknown identifiers and compare the public status, body shape, and broad timing distribution. Expire a challenge between assessment and commit. Then change the risk-policy version during a request and verify that the ledger records the version actually used.
Where this approach does not fit
The catch is that risk-based CAPTCHA requires reliable state, policy ownership, accessible alternatives, and enough telemetry to review false positives. It is not suitable when the team can't operate that decision system or can't offer an equivalent path to users who cannot complete the challenge. In that case, use a simpler recovery flow with conservative rate limits, consistent responses, and support-assisted recovery for exceptional cases; adding an opaque score that nobody can explain makes the audit worse.
CAPTCHA also shouldn't carry the entire defense. OWASP places it alongside controls such as login throttling, account lockout considerations, logging, monitoring, and adaptive authentication. Recovery needs its own rate limits and abuse budgets because an attacker can target the message channel even without solving the final authentication step.
During a challenge-provider outage, fail behavior must follow the risk tier and be written down before the onsale. Low-risk recovery may continue under tighter rate limits; high-risk state changes may pause and direct the user to a staffed recovery path. Your mileage may vary because availability and takeover risk are business decisions, but the system must not improvise them request by request.
Don't ship until support and security can answer four questions from the ledger: which policy made the decision, which side effect was claimed, whether a retry was deduplicated, and how the user can recover when the challenge is inaccessible. If those answers require stitching together browser logs and queue timestamps by hand, the design isn't audit-ready.
Top comments (0)