PHP: Creating a CAPTCHA with no Cookies
As an exercise we're combining our Captcha class which normally requires a SESSION variable, with our new Cryptor class, to implement a CAPTCHA test that doesn't rely on browser cookies.
Be warned, the CaptchaNoCookie class is not for public deployment as-is, as once a valid code/encrypted code pair have been determined, the form can be used repeatedly. Instead use the extended CaptchaNonceNoCookie class.
The CaptchaNoCookie class
In our recent article on two-way encryption in PHP we developed the Cryptor class for encrypting and decrypting strings. Previously, we've also presented code for creating a CAPTCHA, though not (yet) in it's own class.
So what we have here is a stripped down version of our CAPTCHA class where instead of using a SESSION to store the CAPTCHA code for verification, we instead include the code encrypted in a hidden form field.
Our new CaptchaNoCookie class is defined as follows:
<?PHP
namespace Chirp;
class CaptchaNoCookie
{
// Original PHP code by Chirp Internet: www.chirpinternet.eu
// Please acknowledge use of this code by including this header.
protected static $encryption_key;
protected $font = "didot/GFSDidotBold.otf";
protected $fontsize = 28;
protected $code = "";
protected $crypted = "";
public $digits = 6;
public function __construct()
{
self::$encryption_key = gethostname() . __CLASS__;
// generate CAPTCHA code
for($i=0; $i < $this->digits; $i++) {
$this->code .= rand(0, 9);
}
}
public function crypted()
{
if(!$this->crypted) {
$cryptor = new Cryptor(self::$encryption_key);
$this->crypted = $cryptor->encrypt($this->code);
}
return $this->crypted;
}
public function display()
{
// calculate required canvas size
$box = imagettfbbox($this->fontsize, 0, $this->font, "88888");
$boxwidth = abs(round($box[4] - $box[0]) * 1.2);
$boxheight = abs(round($box[5] - $box[1]));
$width = round($boxwidth * 1.2);
$height = round($boxheight * 1.4);
// create image canvas
$image = @imagecreatetruecolor($width, $height) or die("Cannot Initialize new GD image stream");
// background fill
$background = imagecolorallocate($image, 0x66, 0xCC, 0xFF);
imagefill($image, 0, 0, $background);
// allocate line colours
$linecolor = imagecolorallocate($image, 0x33, 0x99, 0xCC);
$textcolor1 = imagecolorallocate($image, 0x00, 0x00, 0x00);
$textcolor2 = imagecolorallocate($image, 0xFF, 0xFF, 0xFF);
// draw random ilnes
for($i=0; $i < 8; $i++) {
imagesetthickness($image, rand(1, 3));
imageline($image, rand(0, $width), 0, rand(0, $width), $height, $linecolor);
}
// paint digits on canvas
for($i=0; $i < $this->digits; $i++) {
$x = ceil($i * $boxwidth / $this->digits);
$angle = rand(-20, 20);
$color = (rand() % 2) ? $textcolor1 : $textcolor2;
$xpos = round($width/10 + $x);
$shim = ($height - $boxheight)/2; // don't ask
$ypos = rand($boxheight - $shim, $boxheight + $shim);
imagettftext($image, $this->fontsize, $angle, $xpos, $ypos, $color, $this->font, $this->code[$i]);
}
// return image as Data URI
ob_start();
imagepng($image);
$image_data = "data:image/png;base64," . base64_encode(ob_get_clean());
imagedestroy($image);
return $image_data;
}
public static function validate($crypted, $user_input)
{
$cryptor = new Cryptor(self::$encryption_key);
$decrypted_token = $cryptor->decrypt($crypted);
return $user_input == $decrypted_token;
}
}
The main change to our earlier CAPTCHA code is that we're displaying the CAPTCHA as an inline Data URI (base64 encoded string) rather than generating an actual image file. That saves us creating an extra script.
The font path is defined relative to the library-defined
font path:
e.g. /usr/share/fonts/truetype
As of PHP 8.0.0, imagettftext() is an alias of imagefttext().
Displaying the CAPTCHA in a form
Displaying the CAPTCHA in your form is a matter of invoking the CaptchaNoCookie to generate both the PNG image and the encrypted digits:
<?PHP
$myCaptcha = new \Chirp\CaptchaNoCookie();
?>
<form method="POST" action="#" accept-charset="UTF-8">
<input type="hidden" name="crypted" value="<?= $myCaptcha->crypted(); ?>">
<p><img src="<?= $myCaptcha->display(); ?>" alt=""></p>
<p>CAPTCHA: <input type="text" required pattern="\d{<?= $myCaptcha->digits; ?>}" name="captcha"></p>
<p><input type="submit"></p>
</form>
The output, with a bit of formatting, and the hidden field made visible, will look something like this:
We're using a touch of HTML5 Form Validation to control user input, and would normally also validate all POST variables in the PHP form handler.
A drawback of the Data URI approach is that we can no longer 'refresh' the image to present a new code without either reloading the entire page or by using Ajax to fetch and insert new values.
Validating user input
When the form is submitted both the digits entered by the user and the encrypted string will be received. We then validate the CAPTCHA by decrypting the encrypted string and comparing it to the user input:
<?PHP
if(!\Chirp\CaptchaNoCookie::validate($_POST['crypted'], $_POST['captcha'])) {
die("Sorry, the CAPTCHA code you entered was not correct!");
}
// CAPTCHA passed validation
?>
Note that because validate() is a static method of the CaptchaNoCookie class we can call it directly without instantiating a new object.
Why it isn't (yet) secure
The above approach seems promising. We're using a high grade encryption to transmit the required digits, and it will be uniquely generated each time by our Captor class, so what's wrong?
The problem is that by simply verifying that the user input matches the encrypted value we're not preventing the same values from being re-submitted over and over again, and it's only a matter of time before some spambot works that out.
Some possible solutions:
Using temporary files
One fix would be to modify the CaptchaNoCookie class so that every time a code is generated, it creates a temporary file on the server. And deletes said file after the code has been successfully used.
touch(/tmp/$crypted)
file_exists(/tmp/$crypted)
unlink(/tmp/$crypted)
That way if the same code is resubmitted, there will be no associated file and verification can be aborted. Unused code files can be garbage-collected.
Embedding a timestamp
We can encode extra information into the encrypted string, such as the timestamp, user ip address, etc, and use that to determine whether the submitted values should be validated.
The CaptchaNonceNoCookie Class
As indicated above, the CaptchaNoCookie class is not usable as-is because it allows the same code to be reused over and over. To that end we've extended the class to add a system for creating and checking a temporary file for each generated CAPTCHA.
<?PHP
namespace Chirp;
class CaptchaNonceNoCookie extends CaptchaNoCookie
{
// Original PHP code by Chirp Internet: www.chirpinternet.eu
// Please acknowledge use of this code by including this header.
private static function tempfile($crypted)
{
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . str_replace(DIRECTORY_SEPARATOR, "_", $crypted);
}
public function display()
{
touch(self::tempfile($this->crypted));
return parent::display();
}
public static function validate($crypted, $user_input)
{
if(file_exists(self::tempfile($crypted))) {
if(parent::validate($crypted, $user_input)) {
unlink(self::tempfile($crypted));
return TRUE;
} else {
// validation failed
}
} else {
// code already used or expired
}
return FALSE;
}
}
The new CaptchaNonceNoCookie class extends the old class and can be called in exactly the same fashion. The only difference being that a temporary file is created when the CAPTCHA is displayed, and deleted after successful validation.
<?PHP
$myCaptcha = new \Chirp\CaptchaNonceNoCookie();
?>
<form method="POST" action="...">
<input type="hidden" name="crypted" value="<?= $myCaptcha->crypted(); ?>">
<p><img src="<?= $myCaptcha->display(); ?>" alt=""></p>
<p>CAPTCHA: <input type="text" required pattern="\d{<?= $myCaptcha->digits; ?>}" name="captcha"><br>
<input type="submit"></p>
</form>
Remember to also use CaptchaNonceNoCookie::validate in your form handler for validation, or you'll simply be collecting tempfiles for no reason.
<?PHP
if(!\Chirp\CaptchaNonceNoCookie::validate($_POST['crypted'], $_POST['captcha'])) {
die("Sorry, the CAPTCHA code you entered was not correct!");
}
// CAPTCHA passed validation
?>
We now have a fully functioning secure CAPTCHA system with single-use codes. Feel free to try it out, and if you have any comments or questions you can get in touch using the Feedback button below.
We use a CRON script to clear out unused temporary files after 30 minutes:
#!/bin/bash
/usr/bin/find /path/to/captcha/files -type f -mmin +30 -delete
Related Articles - Form Validation
- HTML HTML5 Form Validation Examples
- HTML Validating a checkbox with HTML5
- JavaScript Preventing Double Form Submission
- JavaScript Counting words in a text area
- JavaScript Date and Time
- JavaScript Password Validation using regular expressions and HTML5
- JavaScript Tweaking the HTML5 Color Input
- JavaScript Form Validation
- JavaScript Credit Card numbers
- JavaScript Allowing the user to toggle password INPUT visibility
- JavaScript A simple modal feedback form with no plugins
- PHP Protecting forms using a CAPTCHA
- PHP Basic Form Handling in PHP
- PHP Measuring password strength
- PHP Creating a CAPTCHA with no Cookies
Jaey Bee 16 April, 2023
I have tried to embed the captcha image onto a form in a page but I am getting: Cannot Initialize new GD image stream
If you remove the '@' from this line you will see the actual error in the web server logs:
$image = @imagecreatetruecolor($width, $height) or die("Cannot Initialize new GD image stream");
^
Brian 27 February, 2023
Hello-
The last question was posted in 2020 so I don't know if you're monitoring this or not.
In (Microsoft) Code I get the 'Crypto()' is not found. Trying to run your example code and ran into an issue with imagettftext() with $this->code{$i}) but I just dummied around it.
Am I missing a library extension?
Thank you for your help
Brian
You can find our Cryptor class here
As of PHP8 imagettftext is an alias of imagefttext. They are both part of the GD extension (gd.so).
Prior to PHP 8.0.0, strings could be accessed using braces. This curly brace syntax was deprecated as of PHP 7.4.0 and no longer supported as of PHP 8.0.0
Chris 2 August, 2020
Nice script but how can we add a refresh button ?
Like in the chpater 7 from www.the-art-of-web.com/php/captcha/
In the other examples, the CAPTCHA loads as an image file which sets a session/cookie for validation when the form is submitted. Reloading the image also loads a new cookie value.
But here both the CAPTCHA and the validation string are stored in the page using HTML/CSS. You would need an Ajax script to refresh both the hidden form field and the CAPTCHA in the HTML.
AA-T 11 October, 2018
Nice and very useful article, thanks.
Would it be possible to use $_SESSION instead of a temporary file? If so I might give it a try.
The entire point of this article is to create a CAPTCHA without using cookies. This earlier article shows you how to do it with $_SESSION (which is actually a form of temporary file anyway).