CAPTCHA Keeps Coming Back After Solving: How to Fix It
CAPTCHA keeps coming back after solving? The clearance is tied to the IP, User-Agent and TLS that solved it. Solve on a sticky session and reuse the cookies.
When a CAPTCHA keeps coming back after solving, the solve usually worked. What failed is everything after it: the token arrived late or twice, or the clearance cookie got sent from a different exit IP, a different User-Agent or a different TLS handshake than the one that earned it. A site that grants a pass grants it to an identity, and a scraper that rotates right after the solve throws that identity away. Here's how to find which part broke, cheapest check first, and the order of operations that makes a solve last until its cookie expires.
CAPTCHA Keeps Coming Back After Solving: The Two Loops
The loop takes two forms, and they have different causes, so first work out which one you have.
Token-style challenges (reCAPTCHA, hCaptcha, a standalone Turnstile widget). Your solver returns a token, you submit the form, and the page renders the widget again or returns a "verification failed" message. If you control the backend or can see its logs, the verify call usually names the reason. These are the verbatim error codes from each vendor's own documentation, read on 2026-10-30:
reCAPTCHA timeout-or-duplicate "The response is no longer valid: either is too old or has been used previously."
Turnstile timeout-or-duplicate "Token has already been validated."
hCaptcha already-seen-response "The response parameter (verification token) was already verified once."
hCaptcha expired-input-response "The response parameter (verification token) is expired. (120s default)"
Clearance-style challenges (Cloudflare's interstitial, the "Just a moment..." page). The solve succeeds and you get a cf_clearance cookie. The first follow-up request passes, or doesn't, and within a request or two you're back on the interstitial with a 403. Nothing tells you why. Cloudflare's own docs describe the cookie as one that "stores the proof of challenge passed", and that's all a client ever sees.
If you send traffic through the SparkProxy Scraping API, the loop surfaces as a documented HTTP 530 with "reason": "captcha_blocked" and a captcha_type such as cloudflare_turnstile, hcaptcha or recaptcha. Jump to the API section for that case.
A Pass Belongs to the Identity That Earned It
Every fix below follows from one idea. A solved challenge isn't a pass for "your scraper". It's a pass for the specific combination of exit IP, User-Agent, TLS fingerprint and cookie jar that was present when it was solved. Change any of those and, from the site's point of view, a stranger is holding someone else's ticket.
Be precise about what's documented and what isn't. Cloudflare doesn't publish the exact inputs it checks a cf_clearance against. Its docs give the lifetime ("By default, the cf_clearance cookie has a lifetime of 30 minutes", adjustable via Challenge Passage) and say that "If the cookie is still valid, no challenges will be shown." The binding is described most plainly by the solver vendors, whose business depends on getting it right. CapSolver's own AntiCloudflareTask guide, read on 2026-10-30, says:
- "Proxy is necessary, please use static proxy or sticky proxy instead of rotating proxy"
- "Support custom userAgent, please keep it the same as the one you are using."
- "You have to use the TLS request library to request the target website."
2Captcha's Turnstile docs say the same about the browser string for Cloudflare challenge pages: you must "use the userAgent returned from our API." Neither vendor would demand all three if the cookie worked from anywhere.
Token-style CAPTCHAs add a clock. Google states that "Each reCAPTCHA user response token is valid for two minutes, and can only be verified once." Cloudflare gives Turnstile tokens a "Validity period: 300 seconds (5 minutes) from generation" and says "Tokens are single-use." hCaptcha's default is 120 seconds. All three verify APIs also accept the visitor's IP as remoteip (optional for Google and Cloudflare, "Recommended" for hCaptcha), so a site can compare the IP that solved with the IP that submitted.
| Challenge | What you get | Lifetime (vendor docs, 2026-10-30) | Reusable? | Tied to |
|---|---|---|---|---|
| reCAPTCHA v2/v3 | Response token | 2 minutes | No, one verify | Site's choice; `remoteip` optional |
| Turnstile widget | Response token | 300 seconds | No, single-use | Site's choice; `remoteip` optional |
| hCaptcha | Response token | 120 s default | No, one verify | `remoteip` recommended |
| Cloudflare challenge page | `cf_clearance` cookie | 30 min default, site-configurable | Yes, until expiry | Solver vendors: same proxy, UA, TLS |
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Triage in Cost Order
Run these in order. The first two cost almost nothing; the later ones need a solve to test.
| # | Check | Cost | A positive result means |
|---|---|---|---|
| 1 | Token age and reuse | Log two timestamps | You submitted a stale or replayed token |
| 2 | Exit IP before vs after the solve | Two probe requests | Your proxy rotated between solve and reuse |
| 3 | UA and TLS on the follow-up request | One request, inspect headers | The follow-up client isn't the one that solved |
| 4 | Cookies present on the follow-up | Print the outgoing jar | The clearance never left your process |
| 5 | Still looping with all four clean | A few requests | Behavioural or rate trigger; see [below](#still-challenged) |
Check 1: Did the Token Arrive Fresh, Once?
Solver queues are slow when they're busy, and a token that spends 90 seconds in a queue has 30 left on reCAPTCHA's clock. Retries make it worse: an HTTP client that retries a failed POST resubmits the same token, which is exactly what timeout-or-duplicate and already-seen-response are there to catch.
Stamp the token when the solver hands it over and refuse to submit anything past a safety margin:
import time
TTL = {"recaptcha": 120, "hcaptcha": 120, "turnstile": 300} # seconds, per vendor docs
MARGIN = 20 # leave room for the POST itself
class Token:
def __init__(self, kind: str, value: str):
self.kind, self.value = kind, value
self.issued = time.monotonic()
self.used = False
def take(self) -> str:
age = time.monotonic() - self.issued
if self.used:
raise RuntimeError("token already submitted once; solve again")
if age > TTL[self.kind] - MARGIN:
raise RuntimeError(f"token is {age:.0f}s old; solve again")
self.used = True
return self.value
Two related mistakes show up as the same loop. The first is putting the token into g-recaptcha-response (or h-captcha-response, or cf-turnstile-response) but never firing the page's callback, so the site's JavaScript never submits it. 2Captcha's docs spell this out for challenge pages: intercept the callback and "execute the callback passing the token as it's argument." The second is solving with the wrong sitekey or on the wrong page. Google's verify response includes hostname, "the hostname of the site where the reCAPTCHA was solved", so a token solved against another host can be refused even when it's fresh.
Check 2: Did the Exit IP Move After the Solve?
This is the most common cause, and it's the one the organising idea predicts. On a Random Rotation endpoint, every new connection can leave from a different address. The solve happens on one exit and the follow-up arrives from another, carrying a clearance that was issued to the first.
Probe the exit on both sides of the solve:
import requests
ROTATE = "http://USER:PASS@gateway.sparkproxy.io:11000" # Random Rotation
STICKY = "http://USER:PASS@gateway.sparkproxy.io:11002" # Sticky Sessions, up to 5 minutes
def exit_ip(proxy: str) -> str:
r = requests.get("https://api.ipify.org",
proxies={"http": proxy, "https": proxy}, timeout=20)
return r.text.strip()
for name, proxy in (("rotate", ROTATE), ("sticky", STICKY)):
before = exit_ip(proxy)
# ... your solve would happen here ...
after = exit_ip(proxy)
print(f"{name:6} before={before} after={after} same={before == after}")
If the rotating line shows two addresses, you've found the loop. Move both the solve and every request that reuses its result onto Sticky Sessions. Our explainer on what a sticky session proxy is covers the session model, so here are only the two details that matter for CAPTCHAs.
First, the sticky window caps the clearance. A cf_clearance defaults to 30 minutes, but SparkProxy's Sticky Sessions hold an exit for up to 5 minutes. Since the pass is bound to the exit, its useful life is whichever ends first: the cookie or the exit. Plan on roughly one solve per sticky window, and fit as many pages into that window as the site tolerates.
Second, a remote solver has to share your exit. With CapSolver or 2Captcha you pass your proxy into the task so the solve happens on the same IP you'll reuse. That needs user:pass authentication, because the solver's servers aren't on your IP whitelist. Probe your exit right before creating the task, and treat a challenge on the very first reuse as a sign the two sides didn't land on the same address.
Check 3: Do the User-Agent and TLS Still Match?
The classic version of this bug: a headless Chrome solves the challenge, the scraper copies the cookies into requests, and the next request goes out with python-requests/2.34.2 as its User-Agent and Python's OpenSSL handshake. The cookie is right; the client presenting it is obviously not the browser that earned it.
Fix both layers. Take the User-Agent from wherever the solve happened (the browser, or the userAgent field your solver returns), and send it from a client whose TLS handshake looks like that browser. curl_cffi (0.17.0 on PyPI, uploaded 2026-10-26) does the second part with impersonate. Its docs, read on 2026-10-30, list chrome152 as the newest Windows target and say "The chrome alias selects the Windows profile."
from curl_cffi import requests as creq
STICKY = "http://USER:PASS@gateway.sparkproxy.io:11002"
def reuse_client(user_agent: str, cookies: dict) -> creq.Session:
s = creq.Session(impersonate="chrome")
s.proxies = {"http": STICKY, "https": STICKY}
s.headers.update({"User-Agent": user_agent})
for name, value in cookies.items():
s.cookies.set(name, value, domain=".sparkproxy.io")
return s
Keep the claimed browser consistent. A Windows Chrome UA string on a macOS impersonation profile, or a Chrome 152 handshake under a Firefox UA, is another mismatch. If you solved in Playwright's bundled Chromium, read its real UA with page.evaluate("navigator.userAgent") rather than typing one in. Our post on TLS fingerprinting explains what the handshake gives away.
The Working Order: Solve Sticky, Reuse Until Expiry
Put the four checks together and the correct sequence is short:
- Open a Sticky Sessions connection and record the exit IP.
- Solve on that exit, in your browser or by passing the same proxy to your solver.
- Capture every cookie plus the exact User-Agent.
- Reuse them from a browser-matching client on the same sticky exit.
- Re-solve when the exit changes, the cookie expires, or a challenge reappears. Not before.
Here's that loop with CapSolver's documented AntiCloudflareTask as the solver. The endpoints, the ip:port:user:pass proxy format and the solution fields (cookies, token, userAgent) come from its guide; the guide also recommends a session of at least 3 minutes, which the 5-minute sticky window covers.
import time
import requests
from curl_cffi import requests as creq
KEY = "YOUR_CAPSOLVER_KEY"
USER, PASS = "USER", "PASS"
STICKY = f"http://{USER}:{PASS}@gateway.sparkproxy.io:11002"
STICKY_SOLVER = f"gateway.sparkproxy.io:11002:{USER}:{PASS}"
TARGET = "https://www.sparkproxy.io/pricing"
def solve_clearance(url: str) -> dict:
task = {"type": "AntiCloudflareTask", "websiteURL": url, "proxy": STICKY_SOLVER}
r = requests.post("https://api.capsolver.com/createTask",
json={"clientKey": KEY, "task": task}, timeout=30).json()
if r.get("errorId"):
raise RuntimeError(r.get("errorDescription"))
for _ in range(30):
time.sleep(3)
res = requests.post("https://api.capsolver.com/getTaskResult",
json={"clientKey": KEY, "taskId": r["taskId"]}, timeout=30).json()
if res.get("status") == "ready":
return res["solution"]
if res.get("errorId"):
raise RuntimeError(res.get("errorDescription"))
raise TimeoutError("solver did not finish")
def looks_challenged(resp) -> bool:
return resp.status_code in (403, 503) and "Just a moment" in resp.text
class Identity:
"""One sticky exit, one UA, one cookie jar. Replaced as a unit."""
def __init__(self):
self.s = creq.Session(impersonate="chrome")
self.s.proxies = {"http": STICKY, "https": STICKY}
self.ip = self.s.get("https://api.ipify.org", timeout=20).text.strip()
sol = solve_clearance(TARGET)
self.s.headers.update({"User-Agent": sol["userAgent"]})
for name, value in sol.get("cookies", {}).items():
self.s.cookies.set(name, value, domain=".sparkproxy.io")
self.born = time.monotonic()
def expired(self) -> bool:
return time.monotonic() - self.born > 270 # under the 5-minute sticky window
def crawl(urls):
ident = Identity()
for url in urls:
if ident.expired():
ident = Identity()
resp = ident.s.get(url, timeout=30)
if looks_challenged(resp):
ident = Identity() # pass is dead: replace everything
resp = ident.s.get(url, timeout=30)
yield url, resp.status_code
Notice what the code never does: it never rotates the IP while keeping the cookies, and it never keeps the cookies on a new IP. The exit, the UA and the jar live and die together. The 270-second refresh assumes the sticky exit was assigned around the moment the Identity was built; if your logs show a challenge before then, probe the exit (as in Check 2) instead of trusting the timer.
When the Challenge Returns Anyway
If tokens are fresh, the exit holds, the UA and TLS match and the jar travels, but the challenge still comes back, the site is challenging what you do rather than who you are. Three patterns account for most of it:
- Rate. A clearance doesn't buy unlimited requests. Firing 40 requests a second from one freshly cleared identity looks nothing like the visitor who solved, and the site can re-challenge that session on its own rules. Slow the identity down and spread load across several.
- Behaviour on rendered pages. On DataDome, PerimeterX or Akamai, the SparkProxy docs note that
captcha_typeis "null for behavioral blocks", which tells you no CAPTCHA is involved at all. A solver can't help with those. Our guide on how to detect when your scraper is blocked shows how to tell the block types apart. - The site's own rules. Cloudflare warns that "Using Cloudflare Challenges along with Rules features may cause challenge loops." If a custom rule challenges every request to a path, no client fix ends it. That's a loop on the operator's side.
If you're solving dozens of challenges per hour, the better fix is upstream: get challenged less often. That ground is covered in our guide on how to avoid CAPTCHAs when web scraping, and if you're picking a solver, our solver buying guide compares pricing and challenge coverage.
Which Causes a Proxy Settles
| Cause | Can a proxy fix it? | The real fix |
|---|---|---|
| Exit IP rotated between solve and reuse | Yes | Solve and reuse on Sticky Sessions |
| Solver solved on its own IP | Yes | Pass your sticky proxy (user:pass) into the task |
| Clearance outlived the sticky exit | Partly | Re-solve each sticky window |
| Token expired or resubmitted | No | Submit within TTL, once; never retry the POST with the same token |
| Callback never fired, wrong sitekey | No | Fire the page callback; solve on the exact page |
| UA or TLS mismatch on reuse | No | Reuse the solver's UA from an impersonating client |
| Cookies dropped between solve and reuse | No | Export and carry the full jar |
| Rate or behavioural re-challenge | Partly; spreads load | Lower per-identity rate, render properly |
| Site rule that challenges every request | No | Nothing client-side |
Three rows out of nine are things a proxy settles on its own. The rest are client bugs, and a bigger IP pool makes none of them better.
Offloading It to the Scraping API
If you'd rather not run the solve-and-reuse loop yourself, the SparkProxy Scraping API's docs say "Cloudflare, CAPTCHAs, and JS rendering handled automatically." Treat that as our own product claim and test it against your targets with the 1,000 free credits. The base URL is https://scrape.sparkproxy.io/api/v1 with the key in an X-API-Key header. render_js defaults to true and costs 5 credits; country_code adds 5.
One behaviour matters for this topic. In rendered mode the docs state that "Cookies and login state do not persist across separate API requests", and each request gets a fresh browser profile whatever session_id you send. Each call has to clear its own challenge, so you can't solve once and reuse across calls. If you already hold valid cookies, the cookies parameter (rendered mode) injects them before the page loads, and custom_ua sets the User-Agent; the reuse rules above still apply.
Use json_response=true to get the envelope (job_id, status_code, duration_ms, credits_used, body), and decode body from base64 before you look for challenge markers:
import base64
import requests
r = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": "https://www.sparkproxy.io/pricing",
"render_js": "true", # the default; 5 credits
"proxy_type": "premium",
"json_response": "true", # the envelope; body is base64
},
timeout=120,
)
if r.status_code == 530:
err = r.json()
print(err.get("reason"), err.get("captcha_type"), err.get("attempts"))
else:
env = r.json()
html = base64.b64decode(env["body"]).decode("utf-8", "replace")
print(env["status_code"], env["credits_used"], "Just a moment" in html)
A 530 with "reason": "captcha_blocked" comes with the documented error string "CAPTCHA challenge (cloudflare_turnstile) could not be bypassed with the current proxy". The docs say failed requests are refunded automatically, so a loop here costs time rather than credits.
For self-run pipelines on raw proxies, plans start from $75/month (USD, read from sparkproxy.io on 2026-10-09), sold as packs with unlimited bandwidth, so an extra re-solve request doesn't add traffic charges. You get 1M+ premium IPs in 80+ countries, Random Rotation and Sticky Sessions, HTTP/HTTPS/SOCKS5, user:pass or IP whitelist auth (use user:pass when a remote solver shares your exit), and a 24-hour free trial with no credit card.
Frequently asked questions
FAQ
Usually because the follow-up request isn't coming from the identity that solved it. The exit IP rotated, the User-Agent or TLS fingerprint changed, the cookies weren't carried over, or a token expired or was submitted twice. Solve and reuse on one sticky exit with the same UA and full cookie jar.
Cloudflare doesn't publish what it checks the cookie against, but solver vendors such as CapSolver require a static or sticky proxy, the same User-Agent and a TLS-impersonating client. A cookie sent from a new IP, a different UA or a plain Python TLS stack is commonly rejected.
Per each vendor's docs: reCAPTCHA tokens last two minutes, Turnstile tokens 300 seconds, and hCaptcha tokens 120 seconds by default. All three are single-use. Cloudflare's cf_clearance cookie defaults to 30 minutes and site owners can change that.
It's the error reCAPTCHA and Turnstile return when a token is too old or has already been verified. Solve again and submit the new token once, inside its lifetime, without letting an HTTP retry resend it.
Sticky. A clearance is bound to the exit that earned it, so the solve and every reuse should share one exit. SparkProxy's Sticky Sessions hold an exit for up to 5 minutes, which also caps how long a clearance stays useful.
Only partly. A sticky proxy fixes the IP-change causes, but stale tokens, an unfired callback, a UA or TLS mismatch, and dropped cookies are client bugs that no proxy fixes.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
Axios Proxy Not Working for HTTPS: Causes and Fixes
Axios proxy not working for HTTPS? Fix EPROTO, the 400 plain HTTP request sent to HTTPS port error and hangs with proxy:false and https-proxy-agent. Tested.
http_proxy Not Working With sudo, cron and systemd
http_proxy not working with sudo, cron or systemd? Dump the job's real environment, fix env_keep, crontab lines and Environment=, then prove the exit IP.
Website Returning Fake Data to Scraper? Catch Soft Blocks
Website returning fake data to scraper? Catch poisoned prices, fake out-of-stock and truncated results with self-consistency checks and cross-IP votes.
