guides · go · colly

Go and Cloudflare Turnstile

Colly is the most-starred scraping framework in Go and it cannot interact with a Turnstile widget under any configuration — not because of a missing feature, but because there is no JavaScript engine anywhere in its dependency graph. That is a structural fact rather than a limitation to configure around, and it is worth establishing precisely before reaching for chromedp or rod.

Proving it from the dependency graph, not the README

Colly's README does not mention JavaScript, browsers or rendering at all. That absence is suggestive rather than conclusive, so the better evidence is its go.mod and its imports.

The require list contains no JavaScript engine — no goja, no otto, no v8go — and no browser driver. Its HTML-related dependencies are goquery for CSS selection, htmlquery and xmlquery for XPath, chardet for encoding detection, plus robotstxt and a URL parser. The main file's imports are standard library networking plus those parsers.

So the pipeline is: net/http fetches bytes, a parser walks them, your callbacks fire. There is no stage at which a script could run, which means there is no flag, option or extension that changes the outcome for a Turnstile page.

Cloudflare describes the same category from their side, listing as unsupported "Command-line tools such as wget, curl, or others that lack JavaScript execution capabilities required for Cloudflare Challenges." The language is not Go-specific; the property is having no JS engine.

What Colly is genuinely excellent at is everything either side of that: over a thousand requests per second on a single core, automatic cookie and session handling, per-domain rate limiting and concurrency, caching, robots.txt support and distributed scraping.

go
c := colly.NewCollector(
	colly.UserAgent("Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"),
)

c.OnRequest(func(r *colly.Request) {
	r.Headers.Set("X-Requested-With", "XMLHttpRequest")
	r.Headers.Set("Referer", "https://www.example.com/")
})

The three options, and their order

First, look for the data behind the page. A challenge protects a document; it does not always protect the API that document calls. This is the cheapest thing to check and it is frequently the answer — open the network panel, filter to XHR, and see whether the JSON you want answers an ordinary request. Colly is then exactly the right tool.

Second, add a browser. Go has two mature CDP drivers, and they have different strengths:

chromedprod
Stars13,2627,074
Latest module versionv0.16.0 (July 2026)v0.116.2 (July 2024)
LicenceMITMIT
Positioning"a faster, simpler way to drive browsers supporting the Chrome DevTools Protocol in Go without external dependencies""a high-level driver directly based on DevTools Protocol"
Relevant featureHeadless by default"Correctly handles nested iframes or shadow DOMs"

rod's iframe and shadow DOM handling is the one that matters for a Turnstile widget, for the reason the Patchright page goes into: the checkbox sits in an iframe inside a shadow root, and reaching it is a precondition for anything else. Note that rod's last tagged release is considerably older than chromedp's, though its default branch is well ahead of the tag.

go
// chromedp
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()

var title string
if err := chromedp.Run(ctx,
	chromedp.Navigate(ts.URL),
	chromedp.Title(&title),
); err != nil {
	log.Fatal(err)
}

Neither contains Turnstile-specific handling, and neither is a stealth-patched driver in the sense that Patchright or nodriver are in Python — they are plain CDP drivers. Cloudflare's statement that automated browsers are not supported for solving production challenges applies to them as much as to anything else.

Third, get the token elsewhere and keep Colly. This preserves what you chose Go for. Colly stays a fast concurrent HTTP client; only the token comes from somewhere with a browser.

Keeping Colly and attaching a token

The OnRequest callback is the natural place, for the same reason a downloader middleware is in Scrapy: it runs before every request and can attach whatever the target needs.

go
package main

import (
	"bytes"
	"encoding/json"
	"log"
	"net/http"
	"os"

	"github.com/gocolly/colly/v2"
)

type solveReq struct {
	Gate    string `json:"gate"`
	Sitekey string `json:"sitekey"`
	URL     string `json:"url"`
}

type solveRes struct {
	Token  string `json:"token"`
	Status string `json:"status"`
}

func token(sitekey, page string) (string, error) {
	body, _ := json.Marshal(solveReq{Gate: "turnstile", Sitekey: sitekey, URL: page})
	req, err := http.NewRequest("POST", "https://api.solvegate.io/v1/solve", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("SOLVEGATE_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer res.Body.Close()

	var out solveRes
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
		return "", err
	}
	return out.Token, nil
}

func main() {
	c := colly.NewCollector()

	t, err := token("0x4AAAAAAA_target", "https://app.example.com/login")
	if err != nil {
		log.Fatal(err)
	}

	// Post the token as the field the widget would have filled.
	if err := c.Post("https://app.example.com/login", map[string]string{
		"username":              "someone@example.com",
		"password":              os.Getenv("APP_PASSWORD"),
		"cf-turnstile-response": t,
	}); err != nil {
		log.Fatal(err)
	}

	c.OnResponse(func(r *colly.Response) {
		log.Println("response received", r.StatusCode)
	})

	c.Visit("https://app.example.com/")
}

Two details decide whether this works. The field name is cf-turnstile-response unless the page renamed it with data-response-field-nameour checker reports the real one along with the sitekey. And Colly handles cookies automatically through its own jar, which is what you want when a clearance cookie is involved.

Import path note: use github.com/gocolly/colly/v2. The unversioned path is stuck on a 2019 release.

Costs, honestly

The reason to keep Colly is throughput. A browser per worker is orders of magnitude more memory and far fewer concurrent pages, and that is usually the thing Go was chosen to avoid.

A token typically comes back in under 1.5 seconds, which is the number to compare against launching a browser and waiting for a challenge. Prepaid credits run from $0.40 per 1,000 solves down to $0.075 at volume; failed solves are never billed; the first 1,000 are free.

And if the site is yours and the challenge is only in the way of a test, none of this is the right answer — Cloudflare's dummy sitekeys remove it from the test entirely, which is what they exist for. Solve only against properties you own or are authorised to test.

Common questions

No, and no configuration changes that. There is no JavaScript engine anywhere in its dependency graph — no goja, no otto, no v8go, no browser driver — so there is no stage at which a challenge script could execute. Colly fetches bytes with net/http and parses them.

rod documents correct handling of nested iframes and shadow DOMs, which is the relevant property since the widget sits in an iframe inside a shadow root. chromedp is more actively tagged and headless by default. Neither has Turnstile-specific handling, and neither is stealth-patched the way Patchright or nodriver are in Python.

Not one this research identified. chromedp and rod are plain CDP drivers rather than patched builds, so on the stealth axis they sit below the patched Python tools rather than above a plain HTTP client. Treat the absence as unconfirmed rather than proven.

Into the form values you post — cf-turnstile-response, unless the page renamed it with data-response-field-name. Colly's Post takes a map of form values, and its automatic cookie jar carries any clearance cookie forward without extra work.

github.com/gocolly/colly/v2. The unversioned github.com/gocolly/colly path is pinned to a 2019 release. Note also that the v2.3.0 tag exists and is served by the module proxy even though the GitHub Releases page lists an older version.

Related

Sources

More in Guides

Playwright and Cloudflare TurnstileStop Cloudflare Turnstile breaking Playwright tests: use Cloudflare's official test sitekeys on staging, or solve a real widget and inject the token.Python Cloudflare Turnstile SolverSolve Cloudflare Turnstile from Python with plain requests or the solvegate SDK. Complete runnable code, async usage, retries, and error handling.Node.js Turnstile solverSolve Cloudflare Turnstile from Node.js: a zero-dependency global fetch version, the solvegate npm SDK, async/await error handling and TypeScript types.How to find a Turnstile sitekey on a pageFind a Cloudflare Turnstile sitekey in seconds: the data-sitekey attribute, keys starting 0x4, explicit-render calls, and Playwright/Node code that extracts it.How to get the cf-turnstile-response tokenWhat the cf-turnstile-response token is, the hidden input it lives in, how siteverify validates it, and how to get one in your own automated tests.Puppeteer Turnstile bypassHow to handle Cloudflare Turnstile in Puppeteer: Cloudflare's official test sitekeys for pages you own, plus a runnable token-injection script.Selenium and Cloudflare Turnstile in PythonHandle Cloudflare Turnstile in Selenium 4 and Python: test sitekeys, explicit waits, execute_script token injection, and what changes in headless.Turnstile fails on a datacenter IPThe same code passes on your laptop and loops forever on a server. What Cloudflare documents about IP reputation, what it does not, and what actually changes.curl_cffi and Turnstilecurl_cffi impersonates TLS and HTTP/2 fingerprints and has no JavaScript runtime. Its own FAQ says so. What that solves, what it cannot, and where the line is.Scrapy and TurnstileScrapy has no JavaScript engine, so a Turnstile page returns markup and no data. The three documented routes, and which one Scrapy's own docs recommend first.undetected-chromedriver and TurnstileIt binary-patches one string out of chromedriver. Its own README says it does not hide your IP and that headless is unfinished. What that means for Turnstile.SeleniumBase and TurnstileThe uc_gui captcha methods use PyAutoGUI and raise in headless mode — the check is in the source. solve_captcha uses CDP and does not. Which to use where.Camoufox and TurnstileCamoufox is a Firefox fork that patches fingerprints at the C++ level. It ships no Turnstile solver, and the issue asking for one was closed as not planned.nodriver and Turnstilenodriver's README documents a tab.cf_verify() that clicks the Cloudflare checkbox. It is not in the shipped code. What the library does do instead.Patchright and TurnstilePatchright is a drop-in Playwright that closes the Runtime.enable leak and reaches into closed shadow roots — which is exactly where a Turnstile checkbox lives.DrissionPage and TurnstileDrissionPage reaches into non-open shadow roots and switches between browser and HTTP mode. It also forbids commercial use, and its docs are Chinese-only.

Automating a gate you own or are authorised to test?

// SolveGate clears Cloudflare Turnstile and WAF challenges through one REST call · first 1,000 solves free