Solving Isn't Cheating—Injection Timing Is#

Solving a standalone CAPTCHA is easy. Solving a CAPTCHA that sits in the middle of a login, checkout, or signup flow—the way almost every hard target actually deploys them—is a different discipline. The token must arrive at exactly the right moment, on exactly the right session, or the site silently rejects it.

Login Walls#

The most common pattern is a login form protected by reCAPTCHA/hCaptcha. The flow is:

  1. Load the login page and capture all hidden fields (g-recaptcha-response, CSRF tokens, anti-CSRF state).
  2. Extract the site_key from the page markup or the recaptcha bootstrap script.
  3. Submit a solve task to the vendor with the site key and the exact page URL.
  4. Poll until the solution token is ready.
  5. Inject it into the hidden field and POST the whole form together with username/password and the CSRF token.

The hardest part is consistency: the form action URL, the referrer, and the origin registered with the solving vendor must all match what the real client would send, or the token is rejected at verification time.

Checkout Walls#

E-commerce flows frequently gate the checkout but not the browse phase. The CAPTCHA appears only after you add to cart and click through. Because checkout carries a session, the token must be generated for the exact URL and session the checkout uses. A trick used widely: solve the challenge on the pre-checkout page (which is identical structurally) and carry the response token forward into the checkout form, so you never have to pause mid-flow.

Multi-Step Challenges#

Some providers chain challenges: an invisible v3 pre-screen, then a visible v2 or hCaptcha if the score fails, then a second hCaptcha on submit. Each step must be queued and solved in sequence, with the full state machine (page URL, hidden field name, DOM selector of the submit button) tracked as part of the job. Hardcoding a single solve-and-submit pattern breaks the moment a site adds a second step.

Sessions and Proxy Alignment#

Verified tokens are bound to IP and UA at generation time. If your solving vendor resolves the CAPTCHA from a different IP than your request, reCAPTCHA's verification API compares fingerprints and fails the submission. For residential solve tasks, always tell the vendor the same residential IP and user-agent you are using in your own request—or use the proxyless endpoint only when your exit IP is trustworthy.

Error States You Must Handle#

  • "Timeout or duplicate": the token expired or the same token was used twice. Retry the whole solve.
  • "Low score" from v3: no token is ever returned because the session was already scored as a bot. Fix the session, not the solver.
  • "No matches found": the site key or URL you extracted is wrong. Re-extract from live page state instead of cached HTML.

Treat CAPTCHA outages as a queue, not a crash. Each failed solve should increment a per-IP counter so a struggling IP is retired rather than hammered.

Challenges Are Placed at the Expensive Moment#

Where a site drops its challenge tells you what it is protecting and what it will accept as an answer. The distribution is not uniform, and the placement is chosen to break automation specifically.

Placement What it protects What it costs you
Login form credential stuffing, account takeover the whole authenticated session depends on one token
Checkout submit bots buying in bulk, raffle bots state is already mutated when the challenge appears
Search / pagination result harvesting your throughput ceiling drops to one solve per page
First navigation (managed) everything below it cheap to meet once, then free for the TTL
API JSON body programmatic clients tokens live in a header, not a form, and expire the same way

The checkout case is the one that breaks naive pipelines. Browsing is challenge-free; the CAPTCHA appears only after add-to-cart and only on submit. A scraper that assumed "the site has no CAPTCHA" because 5,000 product pages loaded cleanly gets its first challenge on the request that creates an order — the one request that has side effects. If that request is not idempotent, a retry loop is writing duplicate orders.

Escalation Ladders Are Not Static#

The challenge you meet is a function of your score, which means the challenge type changes as your reputation changes. A single account moving from fresh to established typically walks up a ladder: nothing, then a silent v3 assessment, then a managed interstitial, then a visible checkbox, then a hard block. Two consequences follow.

First, your solver must be capability-negotiated at runtime, not configured once. A pipeline that handles only g-recaptcha-response breaks the first time the site upgrades a session to hCaptcha, and it breaks in production, because the escalation is earned. Track the set of widget signatures you have seen, per profile, and treat a new member as a deployment event.

Second, escalation is stateful in the wrong direction. A challenge solved on a profile that was scored down does not clear the score; it just converts a block into a cost. Profiles that eat repeated challenges need to be drained, not fed. The score mechanics are in and the escalation ladder is the defence-side view in .

What an Interstitial Destroys#

When a challenge page replaces a real response, it does not merely block that one request. It mutates origin-side state, and that mutation is the reason naive recovery does not work.

The clean case is a GET: re-request the URL after solving and you are done. The dirty cases are (a) a POST whose body the challenge discarded, so the server never saw your parameters, and (b) a POST the server did process, whose response body it swapped for the challenge page — so the write committed and you have no idea. A four-step checkout with a challenge on the final POST demonstrates all four recovery strategies:

FLOW = [
    {"verb": "GET", "path": "/cart", "needs": ("sid",),
     "makes": ("line_items",)},
    {"verb": "GET", "path": "/cart/checkout", "needs": ("sid", "line_items"),
     "makes": ("checkout_nonce",)},
    {"verb": "POST", "path": "/cart/validate",
     "needs": ("sid", "line_items", "checkout_nonce"), "makes": ("totals",)},
    {"verb": "POST", "path": "/order",
     "needs": ("sid", "checkout_nonce", "totals"), "makes": ("order_id",)},
]

INTERSTITIAL_AFTER = 3
INVALIDATED = ("checkout_nonce",)

STRATEGIES = {
    "resend_same_post": {"restores": (), "fresh_nonce": False, "dupes": True},
    "resend_same_url": {"restores": (), "fresh_nonce": False, "dupes": False},
    "restore_jar_snapshot": {"restores": INVALIDATED, "fresh_nonce": False, "dupes": False},
    "solve_in_own_context": {"restores": (), "fresh_nonce": True, "dupes": False},
}


def run(strategy):
    cfg = STRATEGIES[strategy]
    state = {"sid": "s-7714", "line_items": "3 items"}
    rows = []
    committed = 0
    known = 0
    for i, step in enumerate(FLOW):
        if i == INTERSTITIAL_AFTER and step["verb"] == "POST":
            committed += 1
        if i == INTERSTITIAL_AFTER:
            present = set(state) | set(cfg["restores"])
            if cfg["fresh_nonce"]:
                present.add("checkout_nonce")
            else:
                present -= set(INVALIDATED)
        else:
            present = set(state)
        missing = [k for k in step["needs"] if k not in present]
        if i == INTERSTITIAL_AFTER and cfg["dupes"]:
            status = "duplicate_write"
        elif missing:
            status = "lost:" + ",".join(missing)
        else:
            status = "ok"
            state.update(dict.fromkeys(step["makes"], "made"))
            if "order_id" in step["makes"]:
                known += 1
        rows.append(status)
    return rows, committed, known


ORDER = ["resend_same_post", "resend_same_url", "restore_jar_snapshot",
         "solve_in_own_context"]

print("Interstitial replaces the response to POST /order (step 4 of 4)")
print("")
print("%-22s %-8s %-8s %-8s %-24s %7s %6s" % (
    "strategy", "step 1", "step 2", "step 3", "step 4", "orders", "known"))
for name in ORDER:
    rows, committed, known = run(name)
    print("%-22s %-8s %-8s %-8s %-24s %7d %6d" % (
        name, rows[0], rows[1], rows[2], rows[3], committed, known))
print("")
print("origin-side key the interstitial rotated   " + ", ".join(INVALIDATED))
print("client keys a jar snapshot restores        sid, line_items")
print("client keys a snapshot cannot restore      " + ", ".join(INVALIDATED))
Interstitial replaces the response to POST /order (step 4 of 4)

strategy               step 1   step 2   step 3   step 4                    orders  known
resend_same_post       ok       ok       ok       duplicate_write                1      0
resend_same_url        ok       ok       ok       lost:checkout_nonce            1      0
restore_jar_snapshot   ok       ok       ok       lost:checkout_nonce            1      0
solve_in_own_context   ok       ok       ok       ok                             1      1

origin-side key the interstitial rotated   checkout_nonce
client keys a jar snapshot restores        sid, line_items
client keys a snapshot cannot restore      checkout_nonce

Only one of the four strategies completes the flow, and the reason is not about solving the CAPTCHA at all. Three of them lose checkout_nonce — a value the origin minted and then rotated when it served the interstitial. Restoring your client-side jar snapshot puts the stale value back, which is worse than useless: it turns a recoverable 409 into a hard failure. The winning strategy is the only one that accepts a new cookie from the interstitial itself.

Two design rules fall out of that table. Every state-mutating request needs an idempotency key before it can be safely retried, because you cannot distinguish "did not happen" from "happened and the reply was swallowed". And any recovery that does not re-read the origin's cookies after the challenge is guessing.

The Pre-Challenge Session Must Survive#

A challenge is not a standalone widget; it is a request in a session that the origin is already judging. Most recovery code destroys the very session it needs to survive, in four ways:

  • A fresh client per solve. Spinning up a clean HTTP session for the challenge drops every cookie the flow accumulated. The interstitial arrives at a visitor with no history, which is the profile most likely to be escalated again.
  • A proxy hop. Solving on a different egress than the one that will submit means the token's IP binding fails verification. The flow does not error clearly; it errors as "invalid token" after the submit.
  • A stripped header set. The challenge page's own scripts expect the same Accept-Language, client hints and TLS posture as the flow. Send a default library header block to the challenge endpoint and the widget's own assessment drops.
  • Clock and locale drift. A challenge page that renders a locale-dependent prompt, evaluated by a solver client with a different timezone, produces a solve whose surrounding telemetry disagrees with your session.

The correct shape is: one long-lived identity (profile, cookie jar, egress, header set) that carries the entire workflow, and a challenge handler that is a step inside that identity rather than a separate service. Cookie-jar survival across these hops is the subject of , and the client-hint set that must survive with it is in .

Managed Challenge Variants Are Not Interstitials#

The interstitial model above assumes a page: a visible challenge between you and the content. Managed challenges do not work that way, and building a pipeline that only handles interstitials fails against the vendors that matter most.

The variants differ in what you can observe and therefore in what your code has to branch on.

Variant What you observe What the pipeline must do
Interstitial page a 200 with a challenge body, or a 403 detect the body, solve, resubmit the same request
Redirect hop a 302 to a challenge origin, then back preserve the original URL, headers and cookies across the hop
Silent JS challenge normal HTML, a script that resolves a promise let the page run; block nothing, wait for the token
Invisible score gate no visible challenge, an escalating status code later no local signal at all; react to the response
Per-action challenge challenge on the POST, not on the GET solve inside the form flow, with the session intact

The last two are the important ones. A silent JS challenge produces no distinguishable response at request time, so a pipeline built around "did I get a challenge page" never fires, and the only observable is the next response. An invisible score gate is worse: there is nothing to detect, so the only correct strategy is to keep the session clean enough that the gate does not appear — which is the escalation-ladder discipline above, applied to the fleet rather than to the session.

The redirect hop has its own failure mode worth naming: the challenge origin is frequently not the origin you requested, so a naive cookie-jar implementation scopes cookies to the wrong host and the resubmission arrives with no session. That is the same class of bug as the clearance-cookie host binding, and the same fix: a cookie jar keyed by registrable domain plus an explicit challenge-domain allowlist.

Where the Token Must Be Produced, Not Merely Obtained#

The single most common structural mistake in a challenge pipeline is treating the token as a value to fetch rather than as an artefact of a specific browser execution.

A token from a two-pass image solver is an image-classification result that the site then exchanges for a token over its own API. That exchange is bound to things a bare HTTP client does not have: the public key the challenge page embedded, a data-cfaction or equivalent parameter carrying a session-specific value, a timestamp with a narrow validity window, and sometimes a callback parameter whose value is itself derived from page state. Fetch the token out of band and submit it from a different client context and the exchange fails — usually as a silently-invalid token rather than an error.

Three places the token has to be produced, in increasing order of fidelity:

  1. Out of band, by the vendor. The classic 2Captcha round trip. Works where the exchange is not session-bound, and gets you roughly the 1.2 ratio from the previous section when latency fits inside the TTL.
  2. Inside your own page context, from the challenge script. Load the challenge in a real browser, let the vendor's own script do the image classification through whatever channel it uses, and read the resolved token out of the page. Higher fidelity and no third party in the loop, and it needs a per-vendor adapter because there is no common interface.
  3. Inside a full session continuation. The browser that received the challenge is the same browser that submits the form, with the same cookie jar, the same TLS session and the same JS heap state. This is the only option that survives a challenge that is bound to page state, and it is the one the recovery matrix above models as solve_in_own_context.

The cost gradient is steep and so is the reliability gradient. Options 1 and 2 are cheap to run and fail on the sites that are hardest, which are exactly the sites where you need this. The practical sequencing is to run option 1 as the default, detect the site-specific failure mode by response code and body shape, and escalate to option 3 only for the hosts where option 1 measurably fails. That keeps the fleet on cheap solves and pays the fidelity cost only where it buys something.

Redirect Loss and Re-entry#

A challenge breaks the assumption that a flow is a linear sequence of URLs. Three specific losses recur:

  • The 302 through the challenge loses the method. A POST that redirects to a challenge page and back arrives at the target as a GET unless the origin sets a 307/308. Your request body is gone and you are re-requesting a URL that needs a body.
  • Referrer-based re-entry fails. Many widget callbacks validate the referrer the page was rendered under. Solving the challenge in a separate context with a different navigation history produces a token whose referrer does not match the submission's.
  • Deep links are not resumable. A user who lands on POST-requiring state via a deep link — a saved cart, a guest checkout — has no resumable flow at all. The origin must either challenge at the earlier GET or provide a recovery token; when it does neither, that path is not automatable and no amount of solving helps.

Always record, for every challenge you handle, the method, the original URL, the referrer, and the pre-challenge cookie state. That record is both your retry input and the fixture for a regression test, and building it is what turns challenge handling from a retry loop into a state machine.