A captcha on a contact form is one of those controls nobody looks at once it is in place. The widget renders, the form submits, and the log says the check ran. Over 2026 we found four separate ways in which the reCAPTCHA Enterprise check on our own Yii2 sites validated nothing at all, while every one of those signals stayed green. None of them was a bug in Google's service. All four were in our validator, and each one was invisible from the outside.
This post walks through the four, in the order we found them, and ends with the validator shape we now use on every site.
1. A score threshold that could never be crossed
reCAPTCHA Enterprise returns a risk score from 0.0 (almost certainly a bot) to 1.0 (almost certainly a person). Our validator compared it with a threshold:
if (!$valid || $score < $this->minScore) {
// reject
}
The threshold was declared as public float $minScore = 0.0;. No score is below 0.0, so the second half of that condition could never be true. The assessment was requested, the score was logged, and then it was thrown away. Token validity became the only check, and any bot that loads the page and runs the Enterprise JavaScript gets a perfectly valid token.
On one of our sister sites the contacts table showed the effect when we finally looked: 48 of 64 enquiries were bot submissions with random-string names, arriving steadily from February to August 2026. The application log contained no reCAPTCHA warning and no API error, because nothing had failed. The verdict had simply been ignored. We fixed it on 26 August. A third site was still carrying the same default, and its log showed what that meant: two submissions accepted with valid=true score=0.00, scored by Google as certainly automated and let through anyway.
2. A skip that the client controlled
Yii's ActiveForm can validate fields over AJAX as the user leaves each one. A reCAPTCHA token can only be assessed once, so to stop blur-time validation from consuming it, our validator returned early on AJAX requests:
if (Yii::$app->request->isAjax && Yii::$app->request->post('ajax')) {
return; // skip
}
Both conditions come from the client: isAjax reads the X-Requested-With header, and ajax is just a POST field. Anyone who adds those two to a forged request skips the check entirely, on any action that validates without first returning from its own AJAX branch. On pinuno.com.gh that was ten public forms. On our language-learning app it was the login, registration, password reset and verification resend forms, which passed with an empty token. We removed the skip on every site on 4 October 2026.
The right place to protect the token is the controller, not the validator: when a form does use AJAX validation, validate every attribute except the token.
return ActiveForm::validate(
$model,
array_values(array_diff($model->activeAttributes(), ['reCaptcha']))
);
3. An action nobody compared
Each token carries the action name the page asked for, such as submit or login. The PHP client lets you call setExpectedAction() on the assessment request, and it is natural to assume Google will then reject a token minted for a different action. It does not. The assessment comes back valid, and the action it actually saw is in the token properties for you to check. Until we compared it ourselves, a token generated on one form was accepted on any other.
$action = $response->getTokenProperties()->getAction();
if ($action !== $this->expectedAction) {
// reject
}
4. A check that failed open, and logged where nobody looked
The validator records its verdict at info level under a recaptcha category. On that third site the only log target captured errors and warnings, so every line like valid=true score=0.90 action=submit was written and immediately discarded. That left no way to tell a working captcha from a broken one.
It mattered because the validator fails open when the API call itself fails: a dead credential lets every submission through and, from the outside, looks exactly like a quiet contact form. That is how a zero-byte service-account key went unnoticed on that site. On 19 August 2026, while fixing an unrelated configuration problem, we added a log target for the category so that every verdict is kept.
The validator shape we use now
The threshold is nullable and resolved at start-up, from the application parameters or the environment, with 0.5 as the default. Reading the environment matters on our servers because config/params.php is a shared file that a deploy never updates, so a key added in the repository would never reach production; the shared .env can be changed without a deploy.
public ?float $minScore = null;
public function init(): void
{
parent::init();
if ($this->minScore === null) {
$configured = Yii::$app->params['recaptcha_min_score']
?? $_ENV['RECAPTCHA_MIN_SCORE']
?? getenv('RECAPTCHA_MIN_SCORE');
$this->minScore = ($configured === false || $configured === null || $configured === '')
? 0.5
: (float) $configured;
}
}
On top of that:
- No AJAX skip of any kind. The only skip left is for the development environment.
- The token's action is compared with the expected action in our own code.
- Rejections log at
warning, so they reach the ordinary application log, and therecaptchacategory has a target of its own for the info-level verdicts. - The forged-request fixes came with regression tests that send the forged POST and expect a rejection.
What the four have in common
In every case the form worked, the widget rendered and the log was either clean or silent. A captcha that validates nothing looks exactly like one that validates everything, as long as the bots are not causing visible damage. The only reliable check is to attack it: send a forged AJAX POST, send a token for the wrong action, send one that scores 0.0, and make sure each is refused. Then read the stored submissions, not just the log. The 48 bot enquiries on the sister site were in the database the whole time.
If you want to learn to build secure PHP and Yii2 applications like these, have a look at our PHP Application Developer course.