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)
- Sign up at captcha.la and create an app. Note the
app_keyandapp_secret. - Drop the widget script onto your form page.
- 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_githubLoad it with vlucas/phpdotenv or read it manually. Don't hardcode the secret.
form.html — the page with the widget
<!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
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
- Skipping server validation entirely. The number-one mistake. The browser's
onSuccesscallback 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. - 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: falseon a replay. - Forgetting action binding. Always pass the same
actionstring on both client and server. A token issued forsignupshould not validate againstaction: 'login'. CaptchaLa rejects mismatched actions, but only if you actually pass the value. - Trusting
REMOTE_ADDRbehind a CDN. If you're on Cloudflare/Nginx, the real client IP is inX-Forwarded-For. Pass that asclient_ipso the risk score is accurate. - 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.