To solve the initial problem of storing the CAPTCHA answer without server-side state is, you want a signed and encrypted token. When you generate a CAPTCHA on the server side, you take the solution string, information about the request the CAPTCHA is being used for, and a validity period, and combine them into a token structure of some sort (e.g. JSON). You then use authenticated encryption to protect the confidentiality, integrity, and authenticity of that token, and send it to the client. When the client solves the CAPTCHA, they send back the solution along with the encrypted token. The server then validates the authenticity of the token, decrypts it, and checks that the solution is correct and that the other properties of the token are valid for the request. The encrypted token itself can be sent as a cookie.
Authenticated encryption provides confidentiality, integrity, and authenticity. If the user tries to forge a token or modify an existing one, the authenticity tag will not be valid. libsodium's secretbox is a good choice for providing the cryptography here, since it provides authenticated encryption and handles most of the cryptographic design for you. You should be able to find libsodium bindings for pretty much any language, including PHP, .NET, Python, and Ruby.
The cryptographic key you use would be set as a configuration variable on the server. The nonce for each token should be randomly generated. The only deficiency in libsodium's API here is that it doesn't sign the nonce as part of the message, so you have to do that part yourself. It's not overly tricky, though - here's a rough implementation in PHP:
// encrypt & auth a message with a given key and a random nonce
function authenticated_encrypt($message, $key)
{
$base64mode = SODIUM_BASE64_VARIANT_URLSAFE;
// pick a random nonce value. this doesn't need to be confidential.
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
// apply authenticated encryption to the message using the nonce and key
$ciphertext = sodium_crypto_secretbox($message, $nonce, $key);
// encode the nonce and ciphertext as base64 and concat them with a separator
$encodedNonce = sodium_bin2base64($nonce, $base64mode);
$encodedCiphertext = sodium_bin2base64($ciphertext, $base64mode);
$token = $encodedNonce . '|' . $encodedCiphertext;
// generate an auth tag for the ciphertext and nonce together.
// this is important because it prevents tampering with or swapping the nonce
$authTag = sodium_crypto_auth($token, $key);
// encode the auth tag as base64 and prepend it with a separator
$encodedAuthTag = sodium_bin2base64($authTag, $base64mode);
$token = $encodedAuthTag . '|' . $token;
// end result is "authtag|nonce|ciphertext"
return $token;
}
// verify auth and decrypt a message with a given key and a random nonce
function authenticated_decrypt($token, $key)
{
try
{
$base64mode = SODIUM_BASE64_VARIANT_URLSAFE;
// pull the base64 strings apart
$tokenParts = explode('|', $token);
// check we got 3 parts
if (count($tokenParts) != 3)
{
return false;
}
// extract the three base64 strings
$encodedAuthTag = $tokenParts[0];
$encodedNonce = $tokenParts[1];
$encodedCiphertext = $tokenParts[2];
// decode base64 for auth tag and validate that the result was a string
$authTag = sodium_base642bin($encodedAuthTag, $base64mode);
if (!is_string($authTag))
{
return false;
}
// reconstruct the authenticated part of the string
$signedTokenPart = $encodedNonce . '|' . $encodedCiphertext;
// check that the auth tag matches
if (sodium_crypto_auth_verify($authTag, $signedTokenPart, $key) !== true)
{
return false;
}
// decode base64 for nonce and validate that the result was a string
$nonce = sodium_base642bin($encodedNonce, $base64mode);
if (!is_string($nonce))
{
return false;
}
// decode base64 for ciphertext and validate that the result was a string
$ciphertext = sodium_base642bin($encodedCiphertext, $base64mode);
if (!is_string($ciphertext))
{
return false;
}
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
return $plaintext;
}
catch (SodiumException $sx)
{
return false;
}
}
Example usage when generating the CAPTCHA would be as follows:
// get the key used for CAPTCHA tokens from the webapp config
$key = $config->getCaptchaTokenKey();
// get the configured expiry time. this should be short (3-5 mins)
$expiry = $config->getCaptchaTokenExpiry();
$validFrom = time();
$validUntil = $validFrom + $expiry;
// run CAPTCHA captcha generation code you're using
$captcha = generateCaptcha();
// generate the contents of the token, which will be used to validate the solution later
$tokenData = [
"solution" => $captcha->solution,
"user_id" => $request->userId,
"request" => $request->path,
"client_ip" => $request->clientAddress,
"valid_from" => $validFrom,
"valid_until" => $validUntil,
];
$tokenStr = json_encode($tokenData);
// apply authenticated encryption
$token = authenticated_encrypt($tokenStr, $key);
// set the captcha token as a cookie, with the same expiry as the token itself
// set no path or domain info, and mark the cookie with the Secure and HttpOnly flags
setcookie($config->getCaptchaCookieName(), $token, $validUntil, "", "", true, true);
// show the CAPTCHA to the user
$captcha->render();
Checking a user's solution to the CAPTCHA would look something like this:
$userCaptchaAnswer = $request->get("captcha");
if (is_null($userCaptchaAnswer) || !is_string($userCaptchaAnswer))
{
// request didn't include an answer to the CAPTCHA
$response->error( ... );
die();
}
// check that a CAPTCHA cookie was set
$captchaCookieName = $config->getCaptchaCookieName();
if (!array_key_exists($captchaCookieName, $_COOKIE))
{
// missing cookie
$response->error( ... );
die();
}
// check that the cookie is a string, not an array
// cookies may be arrays if the request includes multiples of the same cookie!
$encryptedCaptchaToken = $_COOKIE[$captchaCookieName];
if (!is_string($encryptedCaptchaToken))
{
// multiple cookies with the same name set
$response->error( ... );
die();
}
// attempt to authenticate and decrypt the token
$tokenKey = $config->getCaptchaTokenKey();
$captchaToken = authenticated_decrypt($encryptedCaptchaToken, $tokenKey);
if ($captchaToken === false)
{
// failed to authenticate or decrypt the token
$response->error( ... );
die();
}
// check we got the right data in the token
$tokenData = json_decode($captchaToken, true);
if (is_null($tokenData))
{
// invalid JSON
$response->error( ... );
die();
}
// validation
if (!array_key_exists("solution", $tokenData) ||
!array_key_exists("user_id", $tokenData) ||
!array_key_exists("request", $tokenData) ||
!array_key_exists("client_ip", $tokenData) ||
!array_key_exists("valid_from", $tokenData) ||
!array_key_exists("valid_until", $tokenData))
{
// missing data in token
$response->error( ... );
die();
}
if (!is_string($tokenData["solution"]) ||
!is_int($tokenData["user_id"]) ||
!is_string($tokenData["request"]) ||
!is_string($tokenData["client_ip"]) ||
!is_int($tokenData["valid_from"]) ||
!is_int($tokenData["valid_until"]))
{
// improper data in token
$response->error( ... );
die();
}
// are we before the valid_from timestamp?
if (time() < $tokenData["valid_from"])
{
// the token is not yet valid, somehow
$response->error( ... );
die();
}
// are we after the valid_until timestamp?
if (time() > $tokenData["valid_until"])
{
// the token has expired
$response->error( ... );
die();
}
if (sodium_memcmp($tokenData["client_ip"], $request->clientAddress) !== 0)
{
// the token was issued to a different IP
$response->error( ... );
die();
}
if (sodium_memcmp($tokenData["request"], $request->path) !== 0)
{
// the token was issued for a different type of request
$response->error( ... );
die();
}
if ($tokenData["user_id"] != $request->userId))
{
// the token was issued to a different user
$response->error( ... );
die();
}
if (sodium_memcmp($userCaptchaAnswer, $tokenData["solution"]) !== 0)
{
// user got the CAPTCHA wrong
$response->error( ... );
die();
}
// if we got this far, everything's OK and the CAPTCHA has been validated
(note: I have done my best to check over this code but please do not copy-paste it into production without peer review)
I know it looks like a lot of code, but 80% of it is just validation logic. I recommend that you're at least that rigorous with validation in your own code.
This solution on its own doesn't solve the problem of figuring out that a user has previously solved a CAPTCHA, though. It only protects individual requests. If you need to implement a mechanism by which the server can tell how long ago the user successfully completed a CAPTCHA, you can store that in client-side state, e.g. along with whatever auth token you might be using (e.g. PASETO, or less preferably JWT), or just in its own cookie. The timestamp of the last solved CAPTCHA should be combined with information about the user, and protected by an authenticity record in the same way I showed for the CAPTCHA's nonce. Confidentiality is not required, so just signing the plaintext timestamp is sufficient. The server can then check that the timestamp info is signed, and that the identifying information matches the incoming request (so that one user/IP can't share their signed timestamp with another to avoid a CAPTCHA trigger) before checking whether the timestamp was short enough ago that another CAPTCHA does not need to be completed.
Tying the CAPTCHA to the same request, user, IP, etc. and providing a validity window is critical in preventing re-use attacks. Without these protections the CAPTCHA can be re-used. With these protections, re-use is limited to the same user/IP making the same type of request within a short token validity window.