Skip to content
This is the documentation for pieceofcake2/captcha, a CakePHP 2.x plugin for pieceofcake2/cakephp. About this fork.

Captcha Plugin Documentation ​

The Captcha plugin adds CAPTCHA widgets to CakePHP 2 forms and verifies the tokens on the server. It supports Cloudflare Turnstile, Google reCAPTCHA v2 and v3, and hCaptcha, and you can add your own provider.

Installation ​

Install the plugin with Composer:

composer require pieceofcake2/captcha

The plugin has the cakephp-plugin type, so it is installed into your plugins directory by composer/installers. Set installer-paths in your application's composer.json:

Layoutinstaller-paths
Modern (config/, src/, templates/, plugins/)"plugins/{$name}/": ["type:cakephp-plugin"]
Legacy (app/Config/, app/Plugin/)"app/Plugin/{$name}/": ["type:cakephp-plugin"]

Load the plugin in config/bootstrap.php (legacy layout: app/Config/bootstrap.php). If you already use CakePlugin::loadAll(), this is not necessary:

php
CakePlugin::load('Captcha');

Configuration ​

Configure the provider and the keys with Configure::write('Captcha', [...]), for example in config/bootstrap.php. Do not commit the keys. With the pieceofcake2/app skeleton, put them in config/.env (or set real environment variables in production) and read them with env():

# config/.env
export CAPTCHA_SITE_KEY="0x4AAAAAAA..."
export CAPTCHA_SECRET_KEY="0x4AAAAAAA..."
php
Configure::write('Captcha', [
    'provider' => 'turnstile',
    'siteKey' => env('CAPTCHA_SITE_KEY'),
    'secretKey' => env('CAPTCHA_SECRET_KEY'),
]);

The helper and the component settings are merged over this configuration, so you can use different settings (even a different provider) per controller.

KeyDefaultDescription
providerturnstileturnstile, recaptcha_v2 (alias recaptcha), recaptcha_v3, hcaptcha, or a class name (see Custom Providers).
siteKeyThe public site key. Rendered into the page.
secretKeyThe secret key. Sent only to the verify endpoint.
actionAction name. Rendered into the widget (Turnstile, reCAPTCHA v3) and compared with the action in the verify response. reCAPTCHA v3 defaults to submit.
hostnameExpected hostname, or a list of hostnames. When set, a token solved on another hostname fails. In the component, true uses the host of the current request.
scoreThreshold0.5reCAPTCHA v3 only. The minimum score (0.0 to 1.0). false disables the check.
timeout10Timeout of the verify request in seconds.
retry0Number of retries after a network error or a non-2xx response. Turnstile requests carry an idempotency key, so a retry is safe.
languageWidget language. Turnstile: data-language. reCAPTCHA and hCaptcha: the hl script parameter.
scriptUrlprovider'sOverride the script URL, e.g. https://www.recaptcha.net/recaptcha/api.js.
verifyUrlprovider'sOverride the verify endpoint, e.g. https://www.recaptcha.net/recaptcha/api/siteverify.
nonceCSP nonce added to the script tags (see Content Security Policy).
widget optionsProvider specific options such as theme and size (see Providers).

Rendering the Widget (CaptchaHelper) ​

Add the helper to your controller (or AppController). Settings are merged over the Captcha configuration:

php
public $helpers = ['Captcha.Captcha' => ['theme' => 'light']];

widget(array $options = []) ​

Renders the widget. Call it inside a form:

php
echo $this->Form->create('Contact');
echo $this->Form->input('message');
echo $this->Captcha->widget();
echo $this->Form->end('Send');
html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async="async" defer="defer"></script>
<div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-theme="light"></div>

The provider script adds a hidden field with the token to the form (cf-turnstile-response, g-recaptcha-response or h-captcha-response).

Options:

  • Widget options of the provider, e.g. ['theme' => 'dark', 'size' => 'compact', 'action' => 'login']. They override the configuration.
  • script: The provider script is output before the first widget. Pass false when you output it yourself with script(), or an array of script() options.
  • nonce: CSP nonce for the script tags.
  • Any other key is an HTML attribute (id, class is appended, data-*, ...).

When FormHelper is loaded, the widget unlocks the token fields (FormHelper::unlockField()), so forms protected by SecurityComponent are not black-holed.

script(array $options = []) ​

Returns the provider <script> tag, once per page. Use it when you want the script in the <head>:

php
$this->Captcha->script(['block' => 'script']);
OptionDefaultDescription
blockfalseAppend the tag to this view block instead of returning it.
async, defertrueAdd the async / defer attributes.
noncenonce settingCSP nonce.
renderexplicit to render the widgets yourself with the provider's JavaScript API.
onloadName of a global function called when the script is loaded.
languagelanguage settinghl parameter (reCAPTCHA, hCaptcha).
queryAdditional query parameters, e.g. ['recaptchacompat' => 'off'] for hCaptcha.
oncetrueOutput the tag only once.

provider() ​

Returns the configured CaptchaProviderInterface instance.

Verifying the Token (CaptchaComponent) ​

php
public $components = [
    'Captcha.Captcha' => [
        'actions' => ['add', 'login'],
    ],
];

Automatic verification ​

For the actions in actions (['*'] for all actions), the component verifies the token in startup(), after beforeFilter() and before the action. Only the methods in methods are verified, so the action can still show the form on GET.

SettingDefaultDescription
actions[]Actions verified automatically. ['*'] for all actions.
methods['post', 'put', 'patch']HTTP methods verified automatically.
onFailureredirectredirect: set a flash message and redirect. exception: throw exception. none: only record the result.
flashCAPTCHA verification failed. Please try again.Flash message for redirect. false for none.
flashOptions[]Options for FlashComponent::set(), e.g. ['element' => 'error'].
redirectrefererRedirect URL for redirect.
exceptionForbiddenExceptionException class for exception, e.g. BadRequestException.
trustProxyfalseRead the client IP (and the host for 'hostname' => true) from the proxy headers. See Security Notes.

Any other setting is provider configuration and overrides Configure::read('Captcha').

redirect discards the submitted data. To redisplay the form with the data, use 'onFailure' => 'none' and check the result in the action:

php
public function add()
{
    if ($this->request->is('post')) {
        if (!$this->Captcha->verify()) {
            $this->Contact->invalidate('captcha', __('Please complete the CAPTCHA.'));
        } elseif ($this->Contact->save($this->request->data)) {
            return $this->redirect(['action' => 'index']);
        }
    }
}

verify() reuses the result of the automatic verification, so the token is sent to the provider only once (tokens are single use).

Manual verification ​

php
if (!$this->Captcha->verify()) {
    $errors = $this->Captcha->getErrors(); // e.g. ['timeout-or-duplicate']
}
MethodDescription
verify(?string $token = null, array $settings = []): boolVerify the token. The token defaults to the provider's field in the request data. $settings overrides the configuration for this call, e.g. ['action' => 'login'].
getErrors(): arrayError codes of the last verification.
getResult(): ?CaptchaResultThe last result: isSuccess(), getErrors(), getHostname(), getAction(), getScore(), getData($key) (the decoded verify response).
token(): ?stringThe token in the request data.
requiresVerification(): boolWhether the current request is verified automatically.
provider(array $settings = [])The configured provider.
setHttpSocket(?HttpSocket $http)Use this HttpSocket for the verify requests (e.g. a mock in tests).

Verifying outside a controller ​

php
App::uses('CaptchaFactory', 'Captcha.Lib');
App::uses('CaptchaVerifier', 'Captcha.Lib');

$verifier = new CaptchaVerifier(CaptchaFactory::provider(['action' => 'login']));
$result = $verifier->verify($token, $clientIp);

Providers ​

Cloudflare Turnstile (turnstile) ​

Widget options: action, cData, theme (auto, light, dark), size (normal, flexible, compact), language, appearance (always, execute, interaction-only), execution (render, execute), tabindex, retry, retryInterval, refreshExpired, refreshTimeout, feedbackEnabled, and the callbacks callback, errorCallback, expiredCallback, timeoutCallback, beforeInteractiveCallback, afterInteractiveCallback, unsupportedCallback (names of global functions). They are rendered as the data-* attributes.

  • action is at most 32 characters and cData at most 255 characters (alphanumeric, _ and -). When action is configured, the action in the verify response must match it.
  • Tokens are at most 2048 characters, valid for 300 seconds, and can be verified once. Longer tokens are rejected without a request.
  • Every verify request carries an idempotency_key (a UUID), so retry can resend the same request safely.

Google reCAPTCHA v2 (recaptcha_v2) ​

Renders the "I'm not a robot" checkbox. Widget options: theme (light, dark), size (normal, compact), tabindex, callback, expiredCallback, errorCallback. language is sent as the hl script parameter. The response does not contain the action, so action is not checked. The invisible reCAPTCHA (size invisible) needs your own JavaScript to call grecaptcha.execute().

Where www.google.com is not accessible, set scriptUrl to https://www.recaptcha.net/recaptcha/api.js and verifyUrl to https://www.recaptcha.net/recaptcha/api/siteverify.

Google reCAPTCHA v3 (recaptcha_v3) ​

reCAPTCHA v3 has no widget. widget() renders a hidden g-recaptcha-response input and a small script. When the form is submitted, the script calls grecaptcha.execute() with the action, stores the token in the input and submits the form again (keeping the clicked submit button). Tokens are fetched on submit because they expire after two minutes.

php
echo $this->Captcha->widget(['action' => 'contact']);
php
public $components = ['Captcha.Captcha' => ['actions' => ['add'], 'action' => 'contact', 'scoreThreshold' => 0.7]];
  • action defaults to submit. It may contain alphanumeric characters, / and _. The action in the verify response must match the configured action, so configure the same action in the component as in the widget.
  • scoreThreshold defaults to 0.5. A response without a score or with a lower score fails with score-too-low. Read the score with $this->Captcha->getResult()->getScore(), e.g. to require additional verification instead of rejecting the request. Set scoreThreshold to false to make the decision yourself.

hCaptcha (hcaptcha) ​

Widget options: theme (light, dark), size (normal, compact), tabindex, callback, expiredCallback, chalexpiredCallback, openCallback, closeCallback, errorCallback. language is sent as the hl script parameter.

  • The site key is sent to the verify endpoint (sitekey), as hCaptcha recommends, so tokens issued for another site key are rejected.
  • The hCaptcha script also adds g-recaptcha-response unless it is loaded with recaptchacompat=off: $this->Captcha->widget(['script' => ['query' => ['recaptchacompat' => 'off']]]).
  • The hCaptcha Enterprise score is not checked (a higher hCaptcha score means a more likely bot). Read it with getResult()->getScore().

Error Codes ​

getErrors() returns the provider's error-codes (e.g. invalid-input-response, timeout-or-duplicate, invalid-input-secret, hCaptcha's already-seen-response) and the codes added by the plugin:

CodeDescription
missing-input-responseNo token was submitted. No request is sent.
invalid-input-responseThe token is longer than the provider allows. No request is sent.
missing-input-secretsecretKey is not configured. No request is sent and a warning is logged.
verification-unavailableNetwork error, timeout or non-2xx response from the verify endpoint.
invalid-jsonThe verify endpoint returned something other than a JSON object.
unknown-errorsuccess was not true and there were no error codes.
hostname-mismatchThe hostname in the response does not match hostname.
action-mismatchThe action in the response does not match action.
score-too-lowreCAPTCHA v3 score is missing or below scoreThreshold.

Content Security Policy ​

Pass a nonce to output it on the script tags (the provider script and the reCAPTCHA v3 inline script):

php
Configure::write('Captcha.nonce', $nonce);
// or
public $helpers = ['Captcha.Captcha' => ['nonce' => $nonce]];
// or
echo $this->Captcha->widget(['nonce' => $nonce]);

The providers also need these sources:

ProviderSources
Turnstilescript-src and frame-src https://challenges.cloudflare.com
reCAPTCHAscript-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/
hCaptchascript-src, frame-src, style-src and connect-src https://hcaptcha.com https://*.hcaptcha.com

Testing ​

The providers publish test keys. Use them in development and in browser tests. They must never be used in production.

Cloudflare Turnstile (testing):

Site keyBehaviour
1x00000000000000000000AAAlways passes (visible)
2x00000000000000000000ABAlways blocks (visible)
1x00000000000000000000BBAlways passes (invisible)
2x00000000000000000000BBAlways blocks (invisible)
3x00000000000000000000FFForces an interactive challenge (visible)
Secret keyBehaviour
1x0000000000000000000000000000000AAAlways passes
2x0000000000000000000000000000000AAAlways fails (invalid-input-response)
3x0000000000000000000000000000000AAYields a "token already spent" error (timeout-or-duplicate)

The dummy site keys produce the token XXXX.DUMMY.TOKEN.XXXX, which only the test secret keys accept. The verify response contains "hostname": "example.com", so do not set hostname with the test keys.

reCAPTCHA v2 (FAQ): site key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI, secret key 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe. All verifications pass and the widget shows a warning. reCAPTCHA v3 has no test keys; create a separate key for testing.

hCaptcha (test keys): site key 10000000-ffff-ffff-ffff-000000000001, secret key 0x0000000000000000000000000000000000000000, response token 10000000-aaaa-bbbb-cccc-000000000001. They never show a challenge and always pass.

In unit tests, mock HttpSocket so no request leaves the test:

php
$http = $this->getMockBuilder(HttpSocket::class)->onlyMethods(['post'])->getMock();
$response = new HttpSocketResponse();
$response->code = 200;
$response->body = '{"success":true,"hostname":"example.com"}';
$http->method('post')->willReturn($response);

$this->Controller->Captcha->setHttpSocket($http);

Security Notes ​

  • Fail closed. A missing token or secret key, a network error, a timeout, a non-2xx response and an invalid JSON body all fail the verification. success must be the boolean true.
  • Single use. Tokens can be verified only once and expire (Turnstile: 300 seconds, reCAPTCHA and hCaptcha: 120 seconds). The component caches the result per request, so verify() after the automatic verification does not send the token again.
  • Hostname and action. Set hostname (or true in the component) so tokens solved on another site are rejected, and action to bind a token to a form.
  • Secrets. The secret key and the tokens are never logged. Requests are sent as application/x-www-form-urlencoded POST with TLS peer verification (HttpSocket defaults). Failures are logged as warnings in the captcha scope without the secret or the token.
  • Client IP and proxies. The client IP is sent as remoteip. By default it is CakeRequest::clientIp() with $safe = true, i.e. REMOTE_ADDR. Behind a reverse proxy or a load balancer this is the proxy's address. Set 'trustProxy' => true only when the proxy overwrites X-Forwarded-For (and X-Forwarded-Host for 'hostname' => true); otherwise clients can forge these headers. Invalid IP addresses are not sent.
  • Verify on the server. The widget alone does not protect anything. Always verify the token with the component (or CaptchaVerifier) before acting on the request.

Custom Providers ​

Set provider to a class that implements CaptchaProviderInterface. Extending BaseCaptchaProvider gives you the common behaviour:

php
// src/Lib/MyCaptchaProvider.php (legacy layout: app/Lib/MyCaptchaProvider.php)
App::uses('BaseCaptchaProvider', 'Captcha.Lib/Provider');

class MyCaptchaProvider extends BaseCaptchaProvider
{
    protected string $defaultScriptUrl = 'https://captcha.example.com/api.js';
    protected string $defaultVerifyUrl = 'https://captcha.example.com/siteverify';
    protected string $widgetClass = 'my-captcha';
    protected string $field = 'my-captcha-response';
    protected array $widgetOptions = ['theme' => 'data-theme'];

    public function name(): string
    {
        return 'my_captcha';
    }
}
php
Configure::write('Captcha.provider', 'MyCaptchaProvider'); // or 'MyPlugin.MyCaptchaProvider'

The class is loaded with App::uses() from Lib (or the plugin's Lib) unless it is already loaded. It is constructed with the merged configuration array.

References ​

See LICENSE for copyright and license information. Not affiliated with the Cake Software Foundation.