CAPTCHA Solving 20 min read

How to Add reCAPTCHA to a PHP Form (No CAPTCHA)

Add Google reCAPTCHA to a PHP form in 2026: create the key in Google Cloud Fraud Defense, render the widget, verify the token server-side. Covers the 10,000-assessment free ceiling and the fail-open quota response.

ST
Scraping.Pro Team
Data collection for business needs
Published: 13 December 2025

A PHP form with a reCAPTCHA widget on it can stop protecting anything, silently, with nothing in your error log. Google documents the mechanism on its own quotas page: once your organization passes 10,000 assessments in a calendar month, requests "fail in an open state. The API returns an HTTP 200 (OK) status. The response body indicates success:true but contains a static score of 0.9 and an error message indicating that you exceeded the quota." Your PHP reads success, sees true, and accepts the submission. So does every bot pointed at the same form.

That ceiling is news to most people who wired reCAPTCHA into a PHP site a few years ago, back when the answer to "what does it cost" was "nothing, up to a million calls a month." reCAPTCHA now sits inside Google Cloud Fraud Defense, the no-billing free tier is 10,000 assessments a month per organization, and every page of the old developer documentation carries a deprecation banner. The integration code did not change. What it costs, what happens at the limit, and which key type Google recommends all did.

What follows is the three-step build, then the parts that decide whether it holds up: where it breaks, what it costs at volume, and when a CAPTCHA is the wrong control. Every price, quota, version and quotation below was read from the vendor's own documentation, pricing page or package registry on 13 August 2026.

Versions this was written against. PHP 8.5, released 20 November 2025, with 8.4 still in active support until 31 December 2026, and the official google/recaptcha library at 1.5. One line in the previous version of this article is now deprecated because of a PHP change. That correction is in Step 3.

What reCAPTCHA costs now, and what the limit does

Three tiers, read from Google Cloud's reCAPTCHA product page on 13 August 2026:

Tier Price Commitment Mobile SDKs
Essentials Free up to 10,000 assessments None No
Premium 0–10,000 free; 10,001–100,000 $8.00 flat; above that $1.00 per 1,000 Monthly, pay as you go Yes
Enterprise $1 per 1,000 assessments, fixed monthly volume commitment 12-month minimum Yes

The footnote under that table is the part that catches people: "The free 10,000 assessments are per organization. The limit aggregates use across all accounts and all sites." Not per key, not per project, not per domain. One busy marketing site can eat the allowance that your five internal forms were quietly living on.

What happens past the line depends on which API you call, and the difference is the whole ballgame. Google's billing page says an Essentials project gets Resource Exhausted (429) on the assessment API. The quotas page says the siteverify endpoint, which is exactly what the PHP below uses, fails open instead: HTTP 200, success:true, a hardcoded score of 0.9. reCAPTCHA v2 users see the widget print "This site is exceeding reCAPTCHA quota." Nobody parsing success in PHP sees anything at all.

Fail-open is a defensible product decision. A quota accident should not take down a checkout. It is still worth knowing about, because the failure is invisible from the server side unless you look for it.

A number we could not reconcile. The deprecated FAQ still states, in the present tense, that "Site keys are considered over quota if more than 1,000,000 calls per month are used for any domain." The current Cloud Fraud Defense quotas page says 10,000 per organization. Both pages are Google, both were live on 13 August 2026, and one is stamped deprecated. Which figure applies to a classic key that has never been migrated into a Cloud project is not something we can establish from outside. Assume the smaller number, watch your usage, and log the error-codes array so you find out from your own data rather than from a spam wave.

Which key type belongs on a PHP form

A correction against the previous version of this article. It recommended the v2 checkbox as the default for most PHP sites. Google's own key creation page now says the opposite, verbatim: "We do not recommend using checkbox keys because they increase user friction and don't significantly improve accuracy." The same page notes that "Creating a score-based key is the default option in the Google Cloud console." The vendor has stopped recommending the thing this article recommended.

Independent work points the same direction. "Dazed & Confused: A Large-Scale Real-World User Study of reCAPTCHAv2" (Searles, Prapty and Tsudik, arXiv, November 2023) ran a 13-month study across more than 3,600 distinct users and concluded that "reCAPTCHAv2 and similar reCAPTCHA technology should be deprecated," describing it as carrying "an immense cost and no security."

So what do you put on the form?

  • Score-based (v3). No interaction, no puzzle, no widget in the layout. The client calls grecaptcha.execute() with an action name, your server gets a score from 0.0 to 1.0 back from siteverify, and you decide. Google's v3 documentation gives 0.5 as the starting threshold. This is the default for a reason: the friction is zero and you keep the decision. The cost is that it never blocks anything on its own, so a threshold you never tune is the same as no protection.
  • Policy-based challenge. A score-based key that raises an actual challenge itself when the score falls under a threshold you configure. It is the closest thing to "checkbox, but only for the suspicious ones" that the current console offers.
  • Checkbox (v2). Still creatable, still works, and still the only variant that shows the user something. Keep it when the visible proof matters: a low-traffic form where a human wants to see that a control exists, or a page where you would rather show friction than run a silent scoring model. Do not keep it because a tutorial from 2019 said so.
  • Invisible (v2). The checkbox bound to a button, challenge only for suspicious sessions.

The server-side code is identical for all four. Only the response fields you read change.

Step 1: Create the key

Google's current instructions route you through the Cloud console: open the Fraud Defense page, pick the Keys tab, click Create key, name it, choose Web, add your domains, set the key type. The old standalone console at google.com/recaptcha/admin sits behind a Google sign-in, so what it offers today is not something we can show from outside. Google's own documentation no longer sends you there.

You get two values back:

  • Site key. Public. It goes in your HTML. Publishing it is the design, not a leak.
  • Secret key. Private. It lives on your server, in an environment variable or a secrets manager, and it never appears in HTML, JavaScript, a public endpoint, or a git repository. If it ever does, rotate it the same hour.

Add localhost to the domain list while developing, and take it out before the key goes near production.

Domain validation is the setting people skip. Keys are bound to the domains you list. There is an advanced option to turn that off, and Google's own domain validation page is blunt about the consequence: turning the protection off "poses a large security risk," and if you do it you are "required to check the hostname/package field and reject any solutions that are coming from unexpected sources." A token solved on a widget you do not control verifies perfectly well against your secret. The hostname field in the response is what tells them apart, and the PHP in Step 3 checks it.

If your users are somewhere www.google.com is not. The FAQ still documents the swap: "If you prefer to not use the www.google.com domain which may have other cookies set, you can use www.recaptcha.net instead." Same script, same widget, different host.

Already have classic keys? Google's migration page is reassuring on the only question that matters here: "Your site keys are available in a Google Cloud project without any changes. Your existing reCAPTCHA integration continues to work without requiring code changes," and "you can continue to use the siteverify method to assess a user's reCAPTCHA response token." Nothing below stops working.

Step 2: Put the widget on the page

Load the script and drop the widget div inside the form. On success the script injects a hidden field named g-recaptcha-response carrying the token:

html
<!DOCTYPE html>
<html>
<head>
  <script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body>
  <form action="verify.php" method="POST">
    <label>Name <input type="text" name="name" required></label>
    <label>Email <input type="email" name="email" required></label>
    <textarea name="message" required></textarea>

    <!-- replace with your real site key -->
    <div class="g-recaptcha"
         data-sitekey="YOUR_SITE_KEY"
         data-expired-callback="onRecaptchaExpired"></div>

    <button type="submit">Send</button>
  </form>
</body>
</html>

The attributes worth knowing, from Google's display documentation: data-theme (light by default), data-size (normal), data-tabindex, data-callback, data-error-callback, and the one almost nobody wires up, data-expired-callback, which fires "when the reCAPTCHA response expires." The script URL takes hl to force a language and render=explicit to defer rendering until you call grecaptcha.render() yourself.

Content Security Policy will break this before anything else does. If you run a CSP, the FAQ lists the exact allowances: script-src needs https://www.google.com/recaptcha/ and https://www.gstatic.com/recaptcha/, frame-src needs https://www.google.com/recaptcha/ and https://recaptcha.google.com/recaptcha/, and connect-src needs https://www.google.com/recaptcha/. Using a nonce? "Make sure to include your nonce in the reCAPTCHA api.js script tag, and we'll handle the rest."

The badge is not optional, the placement is. For invisible and score-based keys Google permits .grecaptcha-badge { visibility: hidden; } on one condition, in its words: "You are allowed to hide the badge as long as you include the reCAPTCHA branding visibly in the user flow."

Step 3: Verify the token server-side

One server-to-server POST to https://www.google.com/recaptcha/api/siteverify with secret, response and optionally remoteip. The browser never sees the secret and never sees Google's answer.

php
<?php
// verify.php
declare(strict_types=1);

const RECAPTCHA_SECRET = 'YOUR_SECRET_KEY';            // env var in real code
const RECAPTCHA_HOSTS  = ['example.com', 'www.example.com'];

/** Returns the decoded payload, or null when the call itself failed. */
function recaptcha_siteverify(string $token, string $remoteIp): ?array
{
    $ch = curl_init('https://www.google.com/recaptcha/api/siteverify');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query([
            'secret'   => RECAPTCHA_SECRET,
            'response' => $token,
            'remoteip' => $remoteIp,
        ]),
        CURLOPT_CONNECTTIMEOUT => 4,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    $raw    = curl_exec($ch);
    $errno  = curl_errno($ch);
    $error  = curl_error($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    // No curl_close() here. See the note below.

    if ($raw === false || $errno !== 0 || $status !== 200) {
        error_log('reCAPTCHA transport: ' . ($errno ? $error : 'HTTP ' . $status));
        return null;
    }

    try {
        $data = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        error_log('reCAPTCHA body: ' . $e->getMessage());
        return null;
    }

    return is_array($data) ? $data : null;
}

function recaptcha_passed(?array $data): bool
{
    if ($data === null) {
        return false;                                  // transport failure
    }

    if (($data['success'] ?? false) !== true) {
        error_log('reCAPTCHA rejected: ' . implode(',', $data['error-codes'] ?? ['none']));
        return false;
    }

    // Required if you ever disable domain validation on the key.
    $host = $data['hostname'] ?? '';
    if ($host !== '' && !in_array($host, RECAPTCHA_HOSTS, true)) {
        error_log('reCAPTCHA hostname mismatch: ' . $host);
        return false;
    }

    // Optional freshness check. Google enforces two minutes already.
    $solvedAt = strtotime((string) ($data['challenge_ts'] ?? ''));
    if ($solvedAt !== false && time() - $solvedAt > 180) {
        error_log('reCAPTCHA stale token: ' . $data['challenge_ts']);
        return false;
    }

    return true;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_POST['g-recaptcha-response'] ?? '';

    if ($token === '') {
        http_response_code(400);
        exit('Please complete the CAPTCHA.');
    }

    if (!recaptcha_passed(recaptcha_siteverify($token, $_SERVER['REMOTE_ADDR'] ?? ''))) {
        http_response_code(403);
        exit('CAPTCHA verification failed. Please try again.');
    }

    // Verified as probably human. Now validate the actual input.
    echo 'Thanks, your message was sent.';
}

The deprecated line. The earlier version of this article called curl_close($ch) twice, which is what every PHP CAPTCHA tutorial still does. The manual is explicit: curl_close() became a no-op in PHP 8.0.0, when the handle turned into a CurlHandle object cleaned up by the garbage collector, and it was deprecated outright in PHP 8.5.0. On 8.5 that line buys you a deprecation notice and nothing else. Delete it. Note the ordering in the code above too: curl_error() and curl_getinfo() are read before anything else touches the handle.

Three details there are worth naming. CURLOPT_SSL_VERIFYHOST => 2 belongs next to VERIFYPEER, because peer verification alone does not check that the certificate matches the host. JSON_THROW_ON_ERROR turns a malformed body into an exception instead of a null that reads as failure. And the freshness check uses 180 seconds, not 120: challenge_ts is Google's clock, your server has its own, and a strict window rejects real users whenever the two drift.

On failing closed. The code above rejects the submission when the network call fails. That is right for a signup or a password reset. On a low-value comment form it means a Google outage takes your form offline, which may be worse than the spam. Decide deliberately, and if you accept on transport failure, log it and rate-limit hard behind it.

The official library, and when to skip it

Google maintains a PHP client. It is at 1.5 on Packagist, tagged 27 April 2026, BSD-3-Clause, roughly 96 million downloads. Watch the PHP constraint: composer.json for 1.5 reads "php": ">=8.4", while the README still says support "moved to PHP 8 and up" as of the 1.3 release. On PHP 8.3 Composer will hold you at 1.4.x.

php
<?php
declare(strict_types=1);

require 'vendor/autoload.php';   // composer require google/recaptcha "^1.5"

$recaptcha = new \ReCaptcha\ReCaptcha(getenv('RECAPTCHA_SECRET'));

$resp = $recaptcha
    ->setExpectedHostname('example.com')
    ->setChallengeTimeout(180)
    ->verify($_POST['g-recaptcha-response'] ?? '', $_SERVER['REMOTE_ADDR'] ?? '');

if ($resp->isSuccess()) {
    // proceed
} else {
    error_log('reCAPTCHA: ' . implode(',', $resp->getErrorCodes()));
}

For a score-based key, add ->setExpectedAction('submit_contact')->setScoreThreshold(0.5). That action check matters more than it looks: without it, a token minted on your own homepage widget verifies happily against your login handler.

The library picks a transport for you. CurlPost is the default when the cURL extension is available (changed in 1.4.2), Post falls back to file_get_contents() with a stream context, and SocketPost opens the socket directly. Shared hosts that disable cURL are why the fallbacks exist.

Take the library for anything with a composer.json. Hand-roll the cURL when you are dropping a single file into a legacy site. The hostname and action checks are the part people forget, and the library does them for free.

Where this breaks

Two minutes, once. Google's verification documentation states it flatly: "Each reCAPTCHA user response token is valid for two minutes" and tokens "can only be verified once to prevent replay attacks." A user who ticks the box, then writes a long message, then submits, arrives with a dead token and gets rejected for no reason they can see. That is why data-expired-callback exists. Wire it to disable the submit button and show a one-line "please tick the box again."

The re-submit after a validation error. Your PHP rejects the form because the email field is malformed, you re-render the page with the values filled in, the user fixes the typo and submits. The token in that hidden field has already been spent. Google returns timeout-or-duplicate and the user is stuck in a loop with no explanation. Call grecaptcha.reset() whenever you re-render a form that failed validation, and treat timeout-or-duplicate in your logs as a bug report about your own flow rather than as an attack.

REMOTE_ADDR behind a proxy. Behind a CDN, a load balancer, or nginx in front of PHP-FPM, $_SERVER['REMOTE_ADDR'] is the proxy. Sending it as remoteip tells Google that every visitor shares one IP. Read the real client address from whatever forwarding header your infrastructure sets, validate it against a trusted proxy list, and if you cannot do that reliably, send nothing. The parameter is optional. A wrong value is worse than an absent one.

AJAX submissions. A form posted with fetch() needs the same server-side check as one posted normally. What your JavaScript sees is advisory. The response your server got from siteverify decides.

Multiple widgets on one page. Two forms, two widgets, one document.getElementById('g-recaptcha-response'). The first match wins and the second form ships an empty token. Use grecaptcha.getResponse(widgetId) with the id returned by grecaptcha.render().

What changes when the form gets busy

A single contact form on a brochure site never approaches any of the limits below. Everything else does.

The 10,000-assessment ceiling arrives faster than it sounds. An assessment is a verification, not a signup. A form taking 350 submissions a day crosses 10,000 on day 29. Put a score-based key on every page load instead of on the submit action, which is a common misconfiguration, and 10,000 page views is the entire monthly allowance. Google's v3 page is direct about this: "make sure to call execute when the user takes the action rather than on page load."

The ceiling is shared across your whole organization. Every project, every key, every site draws on the same 10,000, with no per-project reservation. If your company runs twelve properties, the twelfth to send traffic discovers the limit.

Scores are coarser than the threshold you will write. The assessment page says reCAPTCHA "has 11 levels for scores with values ranging from 0.0 to 1.0," then adds: "Only the following four score levels are available before triggering an automatic security review: 0.1, 0.3, 0.7, and 0.9." Read that before you tune a threshold to two decimals. On the levels Google actually hands out, 0.5 and 0.52 are the same rule. Reason codes such as AUTOMATION and TOO_MUCH_TRAFFIC need a billing account attached before they appear at all.

The rate limit above the monthly one. The quotas page lists 60,000 requests per minute. Batch verification jobs and load tests hit that long before a web form does.

The arithmetic of paying. Premium is free to 10,000, a flat $8.00 for the 10,001–100,000 band, then $1.00 per 1,000. Enterprise is $1 per 1,000 on a twelve-month commitment. Real money for a hobby project, rounding error for a business.

And the price on the other side. 2Captcha lists reCAPTCHA v2 at $1 to $2.99 per 1,000 solves, v3 at $1.45 when a score at or below 0.3 will do and $2.99 when it will not, and Cloudflare Turnstile at $1.45. Ten thousand solved challenges therefore cost an attacker between $10 and $29.90. The same 10,000 is where your free tier ends and siteverify starts answering success:true regardless. Both numbers come from vendor pricing pages read on 13 August 2026. Whether anyone has put the two together in the wild is not something public sources show. The arithmetic is there to be read.

Invisible reCAPTCHA, v3, and the old proxy.php walkthrough

Binding the widget to a button gives you the invisible variant. The callback receives the token, and your JavaScript submits the form from there:

html
<button class="g-recaptcha"
        data-sitekey="YOUR_SITE_KEY"
        data-callback="onSubmit">Send</button>

For a score-based key, siteverify returns two extra fields and you branch on them instead of on a boolean:

php
$data = recaptcha_siteverify($token, $ip);

if (($data['success'] ?? false)
    && ($data['action'] ?? '') === 'submit_contact'
    && ($data['score'] ?? 0) >= 0.5) {
    // likely human
} else {
    // flag, challenge, or queue for review rather than hard-blocking
}

Check the action, not just the score. Google's guidance on what to do with a low score is to "take the action in the background instead of blocking traffic," and action names may "contain only alphanumeric characters, slashes, and underscores." Do not put a user id in there.

If you learned this from a screencast with a proxy.php file in it, delete that file. Bouncing the verification request through a public PHP endpoint so client-side JavaScript could call Google was a workaround for a problem you do not have. It put your verification result on the wire where the browser could see it and, in some versions of that pattern, put the secret somewhere it should never be. The whole integration is the three steps above.

Error reference

The six error codes Google documents for siteverify, with the causes that actually produce them:

Code What it means Usual cause
missing-input-secret The secret parameter is missing Env var unset; the POST body was never built
invalid-input-secret The secret is invalid or malformed Site key pasted where the secret belongs
missing-input-response The response parameter is missing User never solved it; field name typo
invalid-input-response The response is invalid or malformed Token truncated by a length-limited column
bad-request The request is invalid or malformed GET instead of POST; wrong content type
timeout-or-duplicate Too old, or already used Two minutes elapsed, or a re-submit without grecaptcha.reset()

Two failure modes produce no error code at all. A widget reading "Invalid domain for site key" means the domain is not on the key. And curl_exec() returning false is a TLS or network problem on your side: check the CA bundle, and never reach for the option that disables verification.

The alternatives, with their numbers

The server-side shape is the same everywhere: POST a secret and a token, read a boolean. Swapping providers is a URL and a field name.

Cloudflare Turnstile. Free, no image puzzles, and it works on any site without routing traffic through Cloudflare. Three widget modes: managed (decides whether to show a checkbox), non-interactive, and invisible. Verification goes to https://challenges.cloudflare.com/turnstile/v0/siteverify, the field is cf-turnstile-response, and the response carries the same success, challenge_ts, hostname and error-codes you already parse. Two differences favor it: the token is valid for five minutes rather than two, and the endpoint accepts an idempotency_key so a retried validation is safe instead of a timeout-or-duplicate. The free plan allows 20 widgets per account and 10 hostnames each, with no published monthly call ceiling. That last point is the sharpest contrast with reCAPTCHA's 10,000.

hCaptcha. Basic is free. Pro is $139 a month, or $99 billed annually, with 100K monthly evals included and $0.99 per 1,000 after. Enterprise is quote-only. The paid tiers are where the passive mode and risk scores live.

ALTCHA. The odd one out, and worth knowing about if "no third party sees my form traffic" is a hard requirement. The core is MIT-licensed, self-hosted, proof-of-work based rather than behavioral, and there is a PHP server library for verifying the payload. Sentinel, the commercial packaging, was at v1.36.0 on 13 August 2026 — the same day this article was checked, which tells you the project is moving.

Switching the PHP is this small:

php
// reCAPTCHA
$url   = 'https://www.google.com/recaptcha/api/siteverify';
$token = $_POST['g-recaptcha-response'] ?? '';

// Turnstile
$url   = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$token = $_POST['cf-turnstile-response'] ?? '';

Everything else in recaptcha_siteverify() stays where it is.

Where CAPTCHA stops being the answer

A CAPTCHA raises a price. It does not build a wall. OWASP catalogs the attack as OAT-009, CAPTCHA Defeat, sitting next to OAT-017, Spamming, in the same ontology: solving your challenge is treated as an ordinary step in the abuse of valid functionality. The solver market prices that step in single dollars per thousand, and human-powered CAPTCHA-solving services have been selling it for well over a decade.

Thales published its 2026 Bad Bot Report on 30 April 2026: automated traffic accounted for 53% of all observed internet traffic in 2025, with bad bots at 40% and benign automation at 13%. A CAPTCHA is one filter on that flow, and it only sees requests that reach a form.

So layer it:

  • A honeypot field. Hidden with CSS, ignored by humans, filled in by naive form-fillers. Costs nothing and never bothers a real user.
  • Rate limiting per IP and per form. Three submissions a minute from one address is not a person. This is the control that keeps your assessment count under 10,000 in the first place.
  • A timing check. A form submitted 0.4 seconds after it rendered was not typed.
  • Content heuristics. Links in a name field, Cyrillic in a US-only phone field, identical bodies across submissions.
  • Validation regardless. reCAPTCHA proves "probably human." It says nothing about whether the input is safe. Sanitize and validate every field as though the CAPTCHA were not there.

Log rejections with their error code so you can see the shape of what is hitting you. Do not tell the client which control fired. "Submission rejected" is enough; "CAPTCHA failed, but your honeypot was clean" is a free debugging session for whoever is probing you.

Reading how CAPTCHAs get solved is the fastest way to calibrate expectations. The view from the other side is instructive too: teams running managed extraction against protected sites live in the gap between "the widget is present" and "the submission is human."

What to actually do

Create a score-based key in the Cloud console unless you have a specific reason to show users a checkbox. Put the site key in the HTML and the secret in an environment variable. Verify every submission with one server-to-server POST to siteverify, check success, hostname and action, and delete the curl_close() call you copied from a 2019 tutorial. Wire data-expired-callback and call grecaptcha.reset() after a failed validation, because most of what looks like reCAPTCHA breaking is a spent token.

Then watch your assessment count. The free tier is 10,000 a month across your entire organization, and the endpoint this article uses answers success:true with a static 0.9 once you pass it. A control that fails silently open is worth knowing about before it does.

Three steps to install. One number to monitor.