Skip to content

Adding a CAPTCHA to a PHP form takes about ten minutes if you know the API. Most teams burn an afternoon on it because the integration guides skip the part that actually matters: server-side validation. This post walks the full flow — load the widget, get a token, verify it on your backend with Captcha — and shows the three or four mistakes that quietly cost you fraud cleanup down the line.

No framework. Just plain PHP, a <form>, and cURL. If you're on Laravel, see the dedicated Laravel post; if you're on a custom MVC, the verify function below drops in unchanged.

Quick start (2 minutes)

  1. Sign up at captcha.la and create an app. Note the app_key and app_secret.
  2. Drop the widget script onto your form page.
  3. POST the token from the widget to a PHP endpoint that calls apiv1.captcha.la/v1/challenge/verify.

That's it. The app_key is public (it's in the HTML); the app_secret lives only on the server and never touches the browser.

The three files

.env

CAPTCHALA_APP_KEY=your_public_app_key
CAPTCHALA_APP_SECRET=your_secret_keep_this_off_github

Load it with vlucas/phpdotenv or read it manually. Don't hardcode the secret.

form.html — the page with the widget

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Sign up</title>
  <script src="https://cdn.captcha-cdn.net/captchala.js"></script>
</head>
<body>
  <form id="signup" action="/verify.php" method="post">
    <input type="email" name="email" required>
    <input type="password" name="password" required>
    <input type="hidden" name="captcha_token" id="captcha_token">
    <button type="submit" id="submit-btn">Sign up</button>
    <div id="captcha"></div>
  </form>

  <script>
    const widget = window.Captchala.init({
      appKey: 'YOUR_PUBLIC_APP_KEY',
      product: 'bind',
      bindTo: '#submit-btn',
      action: 'signup',
      lang: 'en',
      theme: { custom: { brightness: 'light' } },
    });

    widget
      .onSuccess((res) => {
        document.getElementById('captcha_token').value = res.token;
        document.getElementById('signup').submit();
      })
      .onError((err) => {
        console.warn('captcha failed', err.message);
      })
      .bindTo('#submit-btn');
  </script>
</body>
</html>

Note: the form does not submit on click. The widget intercepts, runs the challenge, calls onSuccess with a token, and then we submit programmatically. If you let the form submit before onSuccess fires, you ship a token-less request.

verify.php — the server side

php
<?php
declare(strict_types=1);

header('Content-Type: application/json');

$token  = $_POST['captcha_token'] ?? '';
$email  = $_POST['email'] ?? '';
if ($token === '' || $email === '') {
    http_response_code(400);
    echo json_encode(['ok' => false, 'error' => 'missing fields']);
    exit;
}

$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
if (str_contains($ip, ',')) {
    $ip = trim(explode(',', $ip)[0]);
}

$payload = json_encode([
    'token'     => $token,
    'action'    => 'signup',
    'client_ip' => $ip,
]);

$ch = curl_init('https://apiv1.captcha.la/v1/challenge/verify');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $payload,
    CURLOPT_TIMEOUT        => 5,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'X-App-Key: '    . getenv('CAPTCHALA_APP_KEY'),
        'X-App-Secret: ' . getenv('CAPTCHALA_APP_SECRET'),
    ],
]);
$body  = curl_exec($ch);
$httpc = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpc !== 200 || $body === false) {
    http_response_code(502);
    echo json_encode(['ok' => false, 'error' => 'verify upstream failed']);
    exit;
}

$resp = json_decode($body, true);
$valid = $resp['data']['valid'] ?? false;
$action = $resp['data']['action'] ?? '';

if (!$valid || $action !== 'signup') {
    http_response_code(403);
    echo json_encode(['ok' => false, 'error' => 'captcha rejected']);
    exit;
}

// Real signup logic goes here. Risk score available in $resp['data']['risk_score'].
echo json_encode(['ok' => true]);

That's a complete, working integration. The whole verify path is under 50 lines and uses only PHP's built-in cURL.

Common pitfalls

  1. Skipping server validation entirely. The number-one mistake. The browser's onSuccess callback is a UX hint, not a security check. An attacker can fake it from DevTools in 30 seconds. You must POST the token to your backend and call the verify endpoint.
  2. Reusing tokens. Each token is single-use. If your form fails another validation (duplicate email, password too short) and the user retries, you need a fresh token — re-trigger the widget. The verify endpoint will return valid: false on a replay.
  3. Forgetting action binding. Always pass the same action string on both client and server. A token issued for signup should not validate against action: 'login'. CaptchaLa rejects mismatched actions, but only if you actually pass the value.
  4. Trusting REMOTE_ADDR behind a CDN. If you're on Cloudflare/Nginx, the real client IP is in X-Forwarded-For. Pass that as client_ip so the risk score is accurate.
  5. HTTP, not HTTPS. The widget refuses to load on insecure origins in production. Make sure your form page is served over TLS.

Where to go next

  • Full PHP examples and the optional Composer package: See on CaptchaLa docs page
  • Pricing and free-tier limits: See on CaptchaLa pricing page.
  • For high-value flows (password reset, payment), look at the server-token issuance endpoint at CaptchaLa — it lets your backend pre-authorize a token bound to a specific IP and TTL, which makes replay attacks essentially impossible.

Ten minutes for the widget, twenty minutes for the verify path, and you're done. The form is now actually defended, not just decorated.

Articles are CC BY 4.0 — feel free to quote with attribution