How to Solve GeeTest v3 in PHP and Post the Result Back

Most CAPTCHA code assumes one token in and one token out. GeeTest does not fit that. A solve returns three values that must be posted together, and the challenge you fed in expires roughly sixty seconds after it was issued. In PHP, where work often gets pushed onto a queue, that expiry causes a very specific and very confusing class of bug.
Two inputs that behave differently
| Value | Lifetime |
|---|---|
gt | Static per site. Safe to cache |
challenge | Single use, dead in about 60 seconds |
Both come from the site’s own GeeTest init endpoint. The gt identifies the site; the challenge is per attempt and perishes quickly.
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, // GeeTest uses this, not defaultTimeout
]);defaultTimeout only governs image CAPTCHAs. Everything interactive, GeeTest included, uses recaptchaTimeout.
Solving
$result = $solver->geetest(
'81388ea1fc187e0c335c0a8907ff2625', // gt, static per site
'7cf6a8b1a2c34d5e6f7089abcdef0123', // challenge, fetched seconds ago
'https://example.com/login'
);
echo $result['challenge'];
echo $result['validate'];
echo $result['seccode'];Those three keys are the answer. $result['code'] is populated as well, but for GeeTest it holds the raw JSON string rather than anything submittable, so grabbing it out of habit produces a puzzling failure.
Use the challenge that comes back, not the one you sent in. They are not always the same.
The whole flow
use CapSkip\CapSkip;
$solver = new CapSkip();
$login = 'https://example.com/login';
// 1. Fresh pair, cache-busted. Cached init responses return spent challenges.
$init = json_decode(file_get_contents(
'https://example.com/geetest/init?t=' . (int) (microtime(true) * 1000)
), true);
// 2. Solve immediately. Nothing slow between here and step 1.
$result = $solver->geetest($init['gt'], $init['challenge'], $login);
// 3. Post all three together with the real form fields.
$ch = curl_init($login);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'geetest_challenge' => $result['challenge'],
'geetest_validate' => $result['validate'],
'geetest_seccode' => $result['seccode'],
'username' => '...',
'password' => '...',
]),
]);
$response = curl_exec($ch);
curl_close($ch);The cache-busting parameter is doing real work. GeeTest init endpoints are often cached by a CDN or reverse proxy, and a cached response hands you a challenge somebody already consumed.
Those three field names are the usual GeeTest v3 convention, but a site can rename them, so check the real form first.
The queue trap, which is worse in PHP
PHP applications lean heavily on job queues, and GeeTest interacts badly with the obvious design. Dispatching a job that carries a gt and challenge looks reasonable and fails as soon as the queue has any backlog:
// WRONG: the challenge expires while the job waits to be picked up. $init = fetchGeetestPair(); dispatch(new SolveCaptchaJob($init['gt'], $init['challenge'], $url));
Fetch the pair inside the worker instead, so the challenge is seconds old when it is used:
// RIGHT: the job fetches its own pair at the moment it runs.
class SolveCaptchaJob
{
public function handle(CapSkip $solver): array
{
$init = fetchGeetestPair(); // called inside the worker
return $solver->geetest($init['gt'], $init['challenge'], $this->url);
}
}PHP executes synchronously and AsyncCapSkip is only an alias here, so parallelism comes from running more workers rather than from the SDK.
Errors
use CapSkip\Exceptions\ValidationException;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\TimeoutException;
try {
$result = $solver->geetest($gt, $challenge, $pageUrl);
} catch (ValidationException $e) {
// missing gt or challenge
} catch (NetworkException $e) {
// CapSkip is not running
} catch (ApiException $e) {
// usually a challenge that expired before the solve finished
} catch (TimeoutException $e) {
// exceeded recaptchaTimeout
}Most GeeTest failures arrive as ApiException and mean the challenge died. Retrying with the same pair never succeeds, because a spent challenge does not become valid again.
Frequently asked questions
Why is $result[‘code’] not usable?
Because the answer is three values rather than one. code keeps the raw JSON for completeness while the SDK expands the useful parts into challenge, validate and seccode. Post those three.
Can I store the challenge in the database?
No. It is single use and expires in about a minute, so anything that persists it will hand a dead value to whatever reads it back. Cache the gt if you like; never the challenge.
Does this cover GeeTest v4?
The geetest method targets v3, the slide puzzle built on a gt and challenge pair. v4 changed the parameter model, so check the current API documentation before assuming the same call applies.
Summary
Fetch a cache-busted pair, solve immediately, then post challenge, validate and seccode together. Use the returned challenge, cache only the gt, and fetch inside the worker rather than passing a challenge through a queue.
Other languages are on the GeeTest solver page, the wider PHP surface on the PHP CAPTCHA solver page, and there is a live puzzle on our GeeTest v3 demo. CapSkip handles captcha bypass locally, so volume costs nothing per solve.
