<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Peak Fo</title>
    <description>The latest articles on DEV Community by Peak Fo (@peakfodev).</description>
    <link>https://dev.to/peakfodev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4114565%2Faab91118-b4bb-459c-a84f-4fdd710840d2.png</url>
      <title>DEV Community: Peak Fo</title>
      <link>https://dev.to/peakfodev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/peakfodev"/>
    <language>en</language>
    <item>
      <title>Why your scraper gets a 403 on Cloudflare Turnstile (and the token-injection fix)</title>
      <dc:creator>Peak Fo</dc:creator>
      <pubDate>Mon, 07 Sep 2026 20:44:01 +0000</pubDate>
      <link>https://dev.to/peakfodev/why-your-scraper-gets-a-403-on-cloudflare-turnstile-and-the-token-injection-fix-59a</link>
      <guid>https://dev.to/peakfodev/why-your-scraper-gets-a-403-on-cloudflare-turnstile-and-the-token-injection-fix-59a</guid>
      <description>&lt;p&gt;Your scraper works fine against the site's HTML, then one day every request comes back &lt;code&gt;403&lt;/code&gt; — or the Turnstile widget just spins forever and never returns a token. Same code, same proxies, nothing changed on your end. Here's what's actually going on and the approach I've landed on after fighting it across a few dozen targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things get you blocked, not one
&lt;/h2&gt;

&lt;p&gt;Cloudflare Turnstile scores the browser, and separately Cloudflare scores the connection. A 403 usually means you failed one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fingerprint.&lt;/strong&gt; Turnstile runs JavaScript that pokes at the browser — canvas, WebGL, timing, the shape of your navigator object, whether events look human. A plain &lt;code&gt;requests&lt;/code&gt; or &lt;code&gt;httpx&lt;/code&gt; client runs none of that JS, so there's nothing to score and the challenge never clears. Headless Chrome runs the JS but leaks automation signals unless you go out of your way to hide them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP reputation.&lt;/strong&gt; This is the one people miss. Cloudflare keeps lists of datacenter ranges. If you're coming from AWS, GCP, Hetzner, OVH, or a cheap datacenter proxy, you can have a perfect fingerprint and still get thrown into an endless challenge, because the IP is the tell.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A stealth browser can fix the first problem. It cannot fix the second. That's why people burn a weekend on &lt;code&gt;undetected-chromedriver&lt;/code&gt; tweaks and still get walls of 403s — they solved fingerprinting and left the IP problem untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token is separate from your request
&lt;/h2&gt;

&lt;p&gt;The thing that unlocks a clean fix: the widget produces a token — &lt;code&gt;cf-turnstile-response&lt;/code&gt; — and on most deployments that token is validated &lt;strong&gt;server-side against the sitekey and the hostname&lt;/strong&gt;, not against the IP that produced it. The site's backend calls Cloudflare's &lt;code&gt;siteverify&lt;/code&gt; with the token and gets back pass/fail.&lt;/p&gt;

&lt;p&gt;So you don't have to make your scraper's browser pass the challenge. You need a valid token for that sitekey and hostname, produced by &lt;em&gt;something&lt;/em&gt; that can run the JS from a residential-looking IP, and then you attach it to your own request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the sitekey
&lt;/h2&gt;

&lt;p&gt;It's sitting in the page. Look for &lt;code&gt;data-sitekey&lt;/code&gt; on the Turnstile div, or a &lt;code&gt;render()&lt;/code&gt; call in the JS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data-sitekey=[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\']([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\']+)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\']&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sitekey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sitekey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. 0x4AAAAAAA...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it's not in the initial HTML it's injected by JS — open DevTools, filter network for &lt;code&gt;turnstile&lt;/code&gt;, and you'll see the sitekey in the &lt;code&gt;challenges.cloudflare.com&lt;/code&gt; request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Producing a token
&lt;/h2&gt;

&lt;p&gt;Two honest options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run a real browser yourself.&lt;/strong&gt; Playwright or Selenium with a genuine profile, pointed through a residential proxy, loading the page and reading the token out of the DOM after the widget resolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# token = page.locator("[name=cf-turnstile-response]").input_value()
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works when your fingerprint is clean &lt;em&gt;and&lt;/em&gt; your IP is residential. It's the cheapest per-token if you already own good proxies and don't mind babysitting browser instances. It's slow (a full browser per solve) and it breaks whenever Cloudflare ships a new challenge variant, which is often.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call a solving API.&lt;/strong&gt; You hand it the URL and sitekey; it runs the browser farm on residential IPs and hands back a token. You keep your own scraper as a plain HTTP client. This is what I reach for when I care more about throughput than about owning the whole stack.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk_your_api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TARGET&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;SITEKEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0x4AAAAAAA...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# 1) get a token — comes back in ~1s
&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.peak.fo/solve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TurnstileTaskProxyLess&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TARGET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sitekey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SITEKEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;solve failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# 2) attach the token to your real request, before it expires
&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TARGET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cf-turnstile-response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the target binds the token to the solving IP (some do), pass a &lt;code&gt;proxy&lt;/code&gt; field so the token is minted through the same residential IP you'll submit from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I work on Peak, the API in that second snippet, so take the mention with that in mind. The API shape is close to interchangeable across providers — the request keys differ but the flow (POST url+sitekey, poll or await, get token) is the same. On price, most providers cluster around $1.20 to $1.45 per 1,000 Turnstile solves. Peak is $0.90 per 1,000 successful solves, dropping toward $0.35 at volume, and you're only billed for tokens that actually come back valid, with about 1,000 free solves to test first. Try whichever; the code above changes by one URL and a couple of JSON keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways a valid token still fails
&lt;/h2&gt;

&lt;p&gt;Once you have a token and it &lt;em&gt;still&lt;/em&gt; doesn't work, it's almost always one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expired.&lt;/strong&gt; Turnstile tokens live about 300 seconds. If you solve, then sit in a queue for five minutes, you're submitting a dead token. Solve right before you submit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reused.&lt;/strong&gt; A token is single-use. The second request with the same &lt;code&gt;cf-turnstile-response&lt;/code&gt; fails. One solve, one submit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong sitekey.&lt;/strong&gt; You grabbed a sitekey from a different widget on the page, or from a cached older version. Re-pull it from the live page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP mismatch.&lt;/strong&gt; The site's backend compares the solving IP to the submitting IP. If yours differ, solve through the same proxy you submit from.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When none of this helps
&lt;/h2&gt;

&lt;p&gt;If the site validates far more than the token — device fingerprint tied to a session, behavioral signals across the whole flow, a &lt;code&gt;cf_clearance&lt;/code&gt; cookie you also need — then a bare token won't carry you, and you're back to driving a full, well-fingerprinted browser through the entire session. Turnstile is one gate; some sites stack several. Fix the token gate first, because it's the one that's cleanly separable, then see what's left.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.peak.fo/cloudflare-turnstile-403-scraper-fix/" rel="noopener noreferrer"&gt;Peak blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>cloudflare</category>
      <category>automation</category>
    </item>
    <item>
      <title>What CAPTCHA solving actually costs (the line items nobody prints)</title>
      <dc:creator>Peak Fo</dc:creator>
      <pubDate>Mon, 07 Sep 2026 20:36:58 +0000</pubDate>
      <link>https://dev.to/peakfodev/what-captcha-solving-actually-costs-the-line-items-nobody-prints-9mb</link>
      <guid>https://dev.to/peakfodev/what-captcha-solving-actually-costs-the-line-items-nobody-prints-9mb</guid>
      <description>&lt;p&gt;The sticker price on a CAPTCHA solving service is almost never what you actually pay. Two services can both say "$1 per thousand" and cost you wildly different amounts by month's end, because the per-solve rate is one line in the bill. The rest hides in failed attempts, proxies, and a plan you didn't need.&lt;/p&gt;

&lt;p&gt;Here's the honest breakdown of where the money goes, and a way to estimate your real cost before you commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure up front:&lt;/strong&gt; I work on Peak, one of these services, so I'll use its numbers as the concrete example. The framework below is vendor-neutral — the point is how to read &lt;em&gt;any&lt;/em&gt; pricing page, not which logo to pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pricing models you'll see
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per-solve (pay as you go).&lt;/strong&gt; You're quoted a rate per thousand solves. At Peak that's $0.90 per 1,000, so $0.0009 a solve — under a tenth of a cent. You add funds and draw them down. No monthly floor, no expiry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subscription / threads.&lt;/strong&gt; Some services sell monthly plans priced by concurrent "threads" rather than solves. It can look cheap per solve at high volume, but you pay the full monthly rate whether you send one request or a million, and unused capacity is gone at month end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credits.&lt;/strong&gt; A middle ground: you buy a bucket of credits, sometimes with an expiry date. Watch the expiry — credits you don't burn in time are just a quiet price increase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost nobody prints: failed solves
&lt;/h2&gt;

&lt;p&gt;This is the big one. Ask any service a single question: do I pay for failed solves?&lt;/p&gt;

&lt;p&gt;If a service bills every attempt, your effective price is the quoted rate divided by your success rate. A "$1 per thousand" service that lands 80% of the time actually costs $1.25 per thousand successful tokens, and you feel it most on hard targets where failures cluster. Peak bills only on a successful solve, so the rate quoted is the rate paid — but the rule holds whoever you use: normalize everyone to cost-per-&lt;em&gt;successful&lt;/em&gt;-solve, or you're comparing fiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other cost: proxies
&lt;/h2&gt;

&lt;p&gt;Most Cloudflare work needs a proxy, and on the challenges that bind to an IP — the interstitial "5-second" check and its &lt;code&gt;cf_clearance&lt;/code&gt; cookie — it needs a sticky residential or mobile one. Proxy bandwidth is a separate bill from your solving spend, and for scraping-heavy jobs it can dwarf the solve cost. Budget for it explicitly instead of getting surprised.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;Say you need 50,000 successful Turnstile tokens this month. On a pay-per-solve service at $0.90 per 1,000 that bills only on success:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50,000 solves x $0.0009 = $45.00
failed solves:            billed nothing
total solving spend:      $45.00  (+ your proxy bandwidth)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same 50,000 on a service that charges per &lt;em&gt;attempt&lt;/em&gt; at the same headline rate, landing 85%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;to get 50,000 successes at 85%, you attempt ~58,800 times
58,800 x $0.0009 = $52.90 for the same 50,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same sticker price, roughly 18% more out of pocket, and the gap widens on harder targets. The number that matters is always cost per successful solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to estimate your bill honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Volume.&lt;/strong&gt; Successful solves per day, realistically — not your peak hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing basis.&lt;/strong&gt; Per success or per attempt? If per attempt, divide the rate by your real success rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy spend.&lt;/strong&gt; Estimate bandwidth separately; it's often the larger line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floor and expiry.&lt;/strong&gt; Any monthly minimum or credit expiry is part of the price.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those four numbers together and you have a budget instead of a headline. If you want to test the per-success model against your own numbers, Peak gives roughly 1,000 free solves before you add funds — enough to measure your real success rate on your actual targets, which is the input every estimate above depends on.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.peak.fo/what-captcha-solving-actually-costs/" rel="noopener noreferrer"&gt;Peak blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>automation</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Bypassing Cloudflare Turnstile from Python without driving a browser</title>
      <dc:creator>Peak Fo</dc:creator>
      <pubDate>Mon, 07 Sep 2026 20:36:22 +0000</pubDate>
      <link>https://dev.to/peakfodev/bypassing-cloudflare-turnstile-from-python-without-driving-a-browser-429i</link>
      <guid>https://dev.to/peakfodev/bypassing-cloudflare-turnstile-from-python-without-driving-a-browser-429i</guid>
      <description>&lt;p&gt;You wrote a clean scraper, pointed it at the target, and Cloudflare Turnstile stopped it dead. Your &lt;code&gt;requests&lt;/code&gt; call comes back with a challenge page instead of data, and retrying changes nothing. Here's why that happens and the exact code I use to get past it.&lt;/p&gt;

&lt;p&gt;The short version: a plain HTTP client can't pass Turnstile because it can't run the JavaScript the widget uses to score the browser. So you stop fighting the widget. You get a valid &lt;code&gt;cf-turnstile-response&lt;/code&gt; token from something that &lt;em&gt;can&lt;/em&gt; run that JS, and you submit the token with your request. That's the whole move, and it's a few lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why requests alone can't do it
&lt;/h2&gt;

&lt;p&gt;Turnstile isn't a puzzle you answer. It's a script that runs in a real browser and scores how browser-like the environment looks — canvas, timing, the shape of the navigator object, whether events look human. A bare &lt;code&gt;requests&lt;/code&gt; or &lt;code&gt;httpx&lt;/code&gt; call has no JavaScript engine, so it can't run that script at all, which means it can never produce the token the server wants. The server sees a missing or invalid &lt;code&gt;cf-turnstile-response&lt;/code&gt; and rejects you.&lt;/p&gt;

&lt;p&gt;This is also why &lt;code&gt;time.sleep()&lt;/code&gt; and retry loops do nothing. Nothing about a retry changes what Cloudflare is scoring. You can loop for an hour; the client still can't run the JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually need
&lt;/h2&gt;

&lt;p&gt;Two moving parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sitekey.&lt;/strong&gt; It's the public Turnstile identifier sitting on the page. Look for &lt;code&gt;data-sitekey&lt;/code&gt; on the Turnstile div, or a &lt;code&gt;turnstile.render()&lt;/code&gt; call in the JS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://target.com/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data-sitekey=[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\']([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\']+)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\']&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sitekey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sitekey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. 0x4AAAAAAA...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it isn't in the initial HTML, it's injected by JS — open DevTools, filter the network tab for &lt;code&gt;turnstile&lt;/code&gt;, and the sitekey shows up in the &lt;code&gt;challenges.cloudflare.com&lt;/code&gt; request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A token.&lt;/strong&gt; You send the page URL, the sitekey, and a proxy to a solving service that runs a real browser on a residential IP, and you get a token back. Then you attach it to your own plain-HTTP request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk_your_api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TARGET&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://target.com/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;SITEKEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0x4AAAAAAA...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PROXY&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://user:pass@ip:port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# 1) get a Turnstile token (comes back in about a second)
&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.peak.fo/solve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turnstiletask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TARGET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sitekey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SITEKEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proxy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PROXY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;solve failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# 2) submit the token with your request, before it expires (~300s)
&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TARGET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cf-turnstile-response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value you get back is a normal &lt;code&gt;cf-turnstile-response&lt;/code&gt;. The server can't tell it apart from one a browser produced, because it isn't different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I work on Peak, the API in that snippet, so weigh the mention accordingly. The flow is close to identical across providers — POST url + sitekey, get a token, attach it — so the code changes by one URL and a couple of JSON keys if you use a different one. On price, Peak is $0.90 per 1,000 successful solves, dropping toward $0.35 at volume, and a miss costs nothing since you're only billed for tokens that come back valid. There's about 1,000 free solves to test with before you add funds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways a valid token still fails
&lt;/h2&gt;

&lt;p&gt;When you have a token and it &lt;em&gt;still&lt;/em&gt; bounces, it's almost always one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expired.&lt;/strong&gt; Turnstile tokens last around 300 seconds and are single-use. Solve right before the request that needs it, not at the top of a long script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reused.&lt;/strong&gt; Each submission needs a fresh token. The server redeems it once and the second request with the same value fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong sitekey.&lt;/strong&gt; A token solved against one sitekey won't validate on a different page. Re-pull it from the live page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP mismatch.&lt;/strong&gt; Some deployments tie the token to the solving context. Solve through the same proxy you submit from, and pass the widget's &lt;code&gt;action&lt;/code&gt; if it sets one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When you need a browser instead
&lt;/h2&gt;

&lt;p&gt;If your flow has to stay on the page and keep interacting after the challenge clears — a session cookie tied to the device, &lt;code&gt;cf_clearance&lt;/code&gt; you also need, behavioral checks across the whole flow — then a bare token won't carry you, and driving a real browser with the token injected makes more sense. For a straight scrape where you just need to get past the gate and read data, the code above is all it takes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.peak.fo/how-to-bypass-cloudflare-turnstile-with-python/" rel="noopener noreferrer"&gt;Peak blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>cloudflare</category>
      <category>webscraping</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
