How to Solve Cloudflare Turnstile in PHP With Composer

solve cloudflare turnstile in php - How to Solve Cloudflare Turnstile in PHP With Composer

Cloudflare Turnstile appears in two forms and they need different PHP. A widget sitting in a form is a two-argument call. A full-page interstitial challenge needs two more values read out of the page, and the token is only accepted if you send back the user agent the solver used. Skip that last part and you get a token that looks completely valid and is rejected every time.

Which one are you looking at?

WidgetChallenge page
AppearanceA checkbox in a form you can still useFull-page interstitial, everything blocked
Needs cData and chlPageDataNoYes
Needs the returned user agentNoYes

Our live Turnstile demo runs the widget variant, which is handy for comparison.

Setup

# PHP 8.0+, with the curl and json extensions.
composer require capskip/capskip
use CapSkip\CapSkip;

$solver = new CapSkip([
    'host' => '127.0.0.1',
    'port' => 8080,
    'recaptchaTimeout' => 300,   // seconds, also covers Turnstile
]);

CapSkip solves locally, so the desktop app has to be running before any call succeeds.

Widget mode

$result = $solver->turnstile(
    '0x4AAAAAAA...',                 // the data-sitekey attribute
    'https://example.com/login'
);

echo $result['code'];                // cf-turnstile-response token

Put $result['code'] into the cf-turnstile-response field and submit the form. That is the whole flow for widgets.

Challenge pages need two more values

An interstitial carries per-request state that the token is bound to, and two parts of it have to travel with the solve:

  • cData, passed as data
  • chlPageData, passed as pagedata

They live inside the challenge page rather than in a form attribute, so the page has to be fetched before it can be solved. On a standard Cloudflare interstitial they sit on the page’s own challenge options object alongside the sitekey. Both are single use and tied to that page load, so fetch and solve together rather than caching them.

$result = $solver->turnstile($sitekey, $pageUrl, [
    'data' => $cData,             // the cData value from the page
    'pagedata' => $chlPageData,   // the chlPageData value
    'action' => 'managed',        // optional, when the page declares one
]);

echo $result['code'];
echo $result['userAgent'];        // needed for the submit

The user agent is mandatory here

Turnstile binds the token to the browser fingerprint that produced it, and the user agent is part of that fingerprint. CapSkip returns the one it used in $result['userAgent']. Submit with cURL’s default user agent instead and Cloudflare rejects a token that is otherwise perfectly good.

userAgent is populated for Turnstile only. It is absent for every other CAPTCHA type, which is why this catches people reusing a working reCAPTCHA helper.

$ch = curl_init($pageUrl);

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    // Send back the exact user agent the solve was performed with.
    CURLOPT_USERAGENT => $result['userAgent'],
    CURLOPT_POSTFIELDS => http_build_query([
        'cf-turnstile-response' => $result['code'],
    ]),
]);

$response = curl_exec($ch);
curl_close($ch);

If a token is being rejected and the cData is fresh, this is nearly always the cause.

Proxies

$result = $solver->turnstile($sitekey, $pageUrl, [
    'data' => $cData,
    'pagedata' => $chlPageData,
    'proxy' => ['type' => 'HTTPS', 'uri' => 'user:[email protected]:3128'],
]);

Solve through the same egress you will submit from when the challenge is geo-sensitive. Proxies work for Turnstile, reCAPTCHA and GeeTest, but not for image CAPTCHAs, which never touch the target site.

Errors

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\TimeoutException;

try {
    $result = $solver->turnstile($sitekey, $pageUrl, $options);
} catch (NetworkException $e) {
    // CapSkip is not running on the configured port
} catch (ApiException $e) {
    // often a stale cData, since it is bound to one page load
} catch (TimeoutException $e) {
    // exceeded recaptchaTimeout
}

Everything lives under CapSkip\Exceptions, unlike the Python and Node SDKs which export exceptions from the package root.

Frequently asked questions

Do widgets ever need cData?

No, and passing empty values makes the solve fail rather than helping. Only full-page interstitial challenges use them.

My token is valid but gets rejected.

Almost always the user agent. Set CURLOPT_USERAGENT to $result['userAgent'] rather than leaving cURL’s default. The next most likely cause is a stale cData, which only survives one page load.

Should this run inside a web request?

Preferably not. PHP is synchronous and a Turnstile solve takes a few seconds, so doing it during a page render blocks a worker. Move it into a queued job or a CLI worker.

Summary

Widgets take a sitekey and a page URL. Challenge pages need data and pagedata read fresh from the page, and the token has to be submitted with $result['userAgent']. Import exceptions from CapSkip\Exceptions, and keep solves off your request threads.

Other languages are on the Cloudflare Turnstile solver page, parameter details in the API documentation, and the wider PHP surface on the PHP CAPTCHA solver page. CapSkip is an unlimited captcha solver running on your own machine.