How to Make a CAPTCHA with PHP and GD
The original post ran in May 2009 and was written for PHP 5 with short tags, mt_rand, and friendly browsers. I've kept the structure and most of the explanation but rewritten the code for PHP 8.2+, added a real expiration check, and finished with the conversation we should be having in 2026: when to even bother rolling your own.
A quick intro to captchas
CAPTCHA is short for Completely Automated Public Turing test to tell Computers and Humans Apart. You know them as those squiggly security images on form pages. The point is to make sure the entity submitting the form is a person, not a script firing off thousands of spam entries an hour.
Captchas aren't perfect. Spammers can solve them manually through cheap labor, and OCR has gotten very good. They still cut casual bot spam to near zero, which is usually what you want. A homemade GD captcha is a great learning project and works fine for low-value forms. It is not what you want guarding a login page.
Designing the captcha
GD is PHP's built-in graphics library. Most hosts have it on by default. The recipe hasn't changed in 16 years:
- Start from a noisy background image so basic OCR has interference to deal with.
- Pick a TrueType font that's a bit irregular. Skip clean monospace fonts, they're easy to read.
- Use a character set without lookalikes. Drop 0, O, 1, l, I, 5, S, Z, 2. People hate guessing.
- Display a random string, store it server side, and never trust the value coming back from the browser.
- Add random rotation, position jitter, and a few random lines or dots to defeat segmentation.
Setting up the image and the session
In 2009 the example used mt_rand. In 2026 you should use random_int, which is cryptographically secure and built in. We also stamp an expiration so a stale captcha can't be reused all day:
<?php
session_start();
// Pick one of 4 background images
$bgIndex = random_int(1, 4);
$im = imagecreatefrompng(__DIR__ . "/backgrounds/{$bgIndex}.png");
// Character pool. We dropped lookalikes: 0, O, 1, l, I, 5, S
$chars = str_split('abcdefghjkmnpqrtuvwxyz23467 89');
$length = 5;
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, count($chars) - 1)];
}
$_SESSION['captcha'] = $code;
$_SESSION['captcha_expires'] = time() + 300; // 5 minute TTLDrawing the text
Each character gets its own angle, color, and small position offset. That's what stops simple segmentation. A handful of random low-contrast lines makes the OCR job worse without making the captcha unreadable to humans:
$font = __DIR__ . '/font.ttf';
$size = random_int(18, 22);
$x = 10;
$y = 30;
foreach (str_split($code) as $i => $char) {
$angle = random_int(-15, 15);
$color = imagecolorallocate($im, random_int(0, 80), random_int(0, 80), random_int(0, 80));
imagettftext($im, $size, $angle, $x + ($i * 22) + random_int(-2, 2), $y + random_int(-3, 3), $color, $font, $char);
}
// Throw in a few random lines so OCR has a harder time
for ($i = 0; $i < 4; $i++) {
$lineColor = imagecolorallocatealpha($im, random_int(0, 120), random_int(0, 120), random_int(0, 120), 60);
imageline($im, random_int(0, 150), random_int(0, 50), random_int(0, 150), random_int(0, 50), $lineColor);
}
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
imagepng($im);
imagedestroy($im);Validating on submit
This is where the 2009 tutorial was weak. Always wipe the session token after a single check, win or lose, so it can't be replayed. Use hash_equals to avoid timing attacks on the comparison. And actually honor the expiration:
<?php
session_start();
$submitted = strtolower(trim($_POST['captcha'] ?? ''));
$expected = $_SESSION['captcha'] ?? '';
$expires = $_SESSION['captcha_expires'] ?? 0;
// Always wipe the token after one use, win or lose
unset($_SESSION['captcha'], $_SESSION['captcha_expires']);
if (time() > $expires) {
http_response_code(400);
exit('Captcha expired. Refresh and try again.');
}
if (!hash_equals($expected, $submitted)) {
http_response_code(400);
exit('Captcha incorrect.');
}
// Passed. Continue processing the form.Hardening the captcha further
- Use 5 or 6 characters instead of 4. The math against random guessing gets much better.
- Lowercase only, then compare with
strtolower. Mixed case frustrates users and barely slows bots. - Rotate fonts and backgrounds, not just colors. Pattern variation matters more than visual chaos.
- Rate limit the captcha endpoint by IP so a bot can't farm thousands of fresh images per second.
- Add a honeypot field on the form. Bots fill it, humans don't see it. Free second line of defense.
When you should NOT roll your own captcha in 2026
If the form is anything sensitive (login, signup, checkout, password reset, contact for a high-value service), use a managed solution. Modern bots running headless Chrome plus a cheap OCR API can blow through a basic image captcha. A custom GD captcha will not save you. Reach for one of:
- Cloudflare Turnstile. Free, privacy-friendly, mostly invisible to real users, and it actually challenges suspicious sessions.
- hCaptcha. Drop-in, GDPR friendly, decent defense for high-traffic forms.
- Google reCAPTCHA v3. Score-based, no challenge for most users, easy to bolt on.
- Honeypot + rate limit + simple math question. For a small business contact form, this combo blocks 95% of spam with zero user friction.
Where this still earns its keep
Internal tools, niche community sites, hobby projects, forms where you don't want a third-party JavaScript dependency, environments where you can't load external services. In those cases, a clean PHP and GD captcha plus a honeypot is plenty.
Further reading
The full GD reference in the PHP manual is still the best resource. DaFont has lots of free fonts that work well as captcha typefaces, just check the license before shipping.
If you'd rather not think about captcha implementations and spam filtering on your business site, that's part of what I handle on web builds.
Get in touch