Backend examples
On the endpoint that receives your form, read the captcha_attestation field
and verify its HMAC locally using your project's secret key. No HTTP round-trip to
captchaapi.eu is needed — verification is a pure-function cryptographic check.
Production-ready verification is five steps. The first four are shown inline per language for pedagogical clarity; the fifth — single-use jti enforcement — is required to defend against replay and is wired into the full snippets in Putting it together:
- Split the attestation at
.— payload segment, signature segment. - Recompute HMAC-SHA256 of the payload segment using the secret key. Compare to the signature segment with a constant-time comparison.
- Base64url-decode the payload, parse as JSON.
- Check
exp >= now()andsk == your_site_key. - Required for production: atomically claim
jtias single-use with a TTL ofexp - now()— see single-use replay protection.
Store secret keys in environment variables. Never commit them to your
repo, never ship them to the browser, never log them. CAPTCHA_SECRET_KEYS
accepts a comma-separated list so you can deploy a new key alongside the current one
during rotation — an attestation is accepted if any key in the list produces a matching
HMAC.
Do not deploy the basic snippets below to production. They show HMAC verification
in isolation for pedagogical clarity — without single-use jti enforcement, a bot
that solves one PoW can replay the same attestation across many requests within its TTL window.
Copy from Putting it together instead — each
language is shown there with mandatory replay protection wired in.
Laravel
Use the official package. For Laravel projects the recommended path iscaptchaapi/laravel— install withcomposer require captchaapi/laraveland you get the HMAC verification rule, multi-secret rotation, replay protection, a Blade component for the widget, and a Livewire trait for nativewire:submitforms, all pre-wired. Source on GitHub.
The hand-rolled snippet below stays as a reference for projects that prefer to vendor the verification logic themselves, or for understanding what the package does internally.
The clean place is a custom validation rule. Put your secrets in config/services.php:
// config/services.php
'captcha' => [
'site_key' => env('CAPTCHA_SITE_KEY'),
'secret_keys' => array_filter(array_map('trim', explode(',', env('CAPTCHA_SECRET_KEYS', '')))),
],
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class CaptchaAttestation implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! str_contains($value, '.')) {
$fail('Captcha verification failed.');
return;
}
[$payloadB64, $sigB64] = explode('.', $value, 2);
$actual = base64_decode(strtr($sigB64, '-_', '+/'), strict: true);
if ($actual === false) {
$fail('Captcha verification failed.');
return;
}
$matched = false;
foreach ((array) config('services.captcha.secret_keys') as $key) {
$expected = hash_hmac('sha256', $payloadB64, $key, binary: true);
if (hash_equals($expected, $actual)) {
$matched = true;
break;
}
}
if (! $matched) {
$fail('Captcha verification failed.');
return;
}
$rawPayload = base64_decode(strtr($payloadB64, '-_', '+/'), strict: true);
if ($rawPayload === false) {
$fail('Captcha verification failed.');
return;
}
$payload = json_decode($rawPayload, true);
if (! is_array($payload)
|| ($payload['exp'] ?? 0) < time()
|| ($payload['sk'] ?? '') !== config('services.captcha.site_key')) {
$fail('Captcha verification failed.');
return;
}
}
}
Use it in any form request:
public function rules(): array
{
return [
'email' => ['required', 'email'],
'captcha_attestation' => ['required', 'string', new CaptchaAttestation()],
];
}
Plain PHP
<?php
// WARNING: verify-only example with NO replay protection. Do not deploy as-is.
// A bot that solves one PoW can replay this attestation until its TTL expires.
// Copy the Putting it together version instead, which wires in jti dedup.
/**
* @param list<string> $secrets Accept any key in this list — enables zero-downtime rotation.
*/
function verifyCaptcha(string $attestation, array $secrets, string $siteKey): bool
{
if (! str_contains($attestation, '.')) return false;
[$payloadB64, $sigB64] = explode('.', $attestation, 2);
$actual = base64_decode(strtr($sigB64, '-_', '+/'), strict: true);
if ($actual === false) return false;
$matched = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $payloadB64, $secret, binary: true);
if (hash_equals($expected, $actual)) {
$matched = true;
break;
}
}
if (! $matched) return false;
$rawPayload = base64_decode(strtr($payloadB64, '-_', '+/'), strict: true);
if ($rawPayload === false) return false;
$payload = json_decode($rawPayload, true);
if (! is_array($payload)) return false;
if (($payload['exp'] ?? 0) < time()) return false;
if (($payload['sk'] ?? '') !== $siteKey) return false;
return true;
}
$secrets = array_filter(array_map('trim', explode(',', getenv('CAPTCHA_SECRET_KEYS') ?: '')));
$ok = verifyCaptcha(
$_POST['captcha_attestation'] ?? '',
$secrets,
getenv('CAPTCHA_SITE_KEY'),
);
if (! $ok) {
http_response_code(422);
exit('Captcha failed.');
}
Node.js
import crypto from 'node:crypto';
function base64UrlDecode(str) {
const pad = str.length % 4;
const padded = pad ? str + '='.repeat(4 - pad) : str;
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
// `secrets` is an array — any matching key accepts the attestation. Enables zero-downtime rotation.
export function verifyCaptcha(attestation, secrets, siteKey) {
if (typeof attestation !== 'string' || !attestation.includes('.')) return false;
const [payloadB64, sigB64] = attestation.split('.', 2);
const actual = base64UrlDecode(sigB64);
const matched = secrets.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(payloadB64).digest();
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
});
if (!matched) return false;
let payload;
try { payload = JSON.parse(base64UrlDecode(payloadB64).toString('utf8')); }
catch { return false; }
if (payload.exp < Math.floor(Date.now() / 1000)) return false;
if (payload.sk !== siteKey) return false;
return true;
}
// Express example
const secrets = (process.env.CAPTCHA_SECRET_KEYS || '').split(',').map(s => s.trim()).filter(Boolean);
app.post('/contact', (req, res) => {
const ok = verifyCaptcha(
req.body.captcha_attestation,
secrets,
process.env.CAPTCHA_SITE_KEY,
);
if (!ok) return res.status(422).send('Captcha failed.');
// proceed with the submission
});
Python
import base64
import hmac
import hashlib
import json
import os
import time
def _b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def verify_captcha(attestation: str, secrets: list[str], site_key: str) -> bool:
"""secrets is a list — any matching key accepts the attestation (enables zero-downtime rotation)."""
if "." not in attestation:
return False
payload_b64, sig_b64 = attestation.split(".", 1)
try:
actual = _b64url_decode(sig_b64)
except Exception:
return False
matched = False
for secret in secrets:
expected = hmac.new(secret.encode(), payload_b64.encode(), hashlib.sha256).digest()
if hmac.compare_digest(expected, actual):
matched = True
break
if not matched:
return False
try:
payload = json.loads(_b64url_decode(payload_b64))
except Exception:
return False
if payload.get("exp", 0) < time.time():
return False
if payload.get("sk") != site_key:
return False
return True
# Flask example
secrets = [s.strip() for s in os.environ.get("CAPTCHA_SECRET_KEYS", "").split(",") if s.strip()]
@app.post("/contact")
def contact():
ok = verify_captcha(
request.form.get("captcha_attestation", ""),
secrets,
os.environ["CAPTCHA_SITE_KEY"],
)
if not ok:
return "Captcha failed.", 422
# proceed
Go
package captcha
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"strings"
"time"
)
type payload struct {
Sk string `json:"sk"`
Exp int64 `json:"exp"`
Jti string `json:"jti"`
Ol bool `json:"ol"`
Iat int64 `json:"iat"`
}
// Verify accepts any key in `secrets` — enables zero-downtime rotation.
func Verify(attestation string, secrets []string, siteKey string) bool {
parts := strings.SplitN(attestation, ".", 2)
if len(parts) != 2 {
return false
}
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false
}
matched := false
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts[0]))
if hmac.Equal(mac.Sum(nil), actual) {
matched = true
break
}
}
if !matched {
return false
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var p payload
if err := json.Unmarshal(raw, &p); err != nil {
return false
}
if p.Exp < time.Now().Unix() {
return false
}
if p.Sk != siteKey {
return false
}
return true
}
Ruby
require 'base64'
require 'json'
require 'openssl'
# `secrets` is an Array — any matching key accepts the attestation. Enables zero-downtime rotation.
def verify_captcha(attestation, secrets, site_key)
return false unless attestation.is_a?(String) && attestation.include?('.')
return false unless secrets.is_a?(Array)
payload_b64, sig_b64 = attestation.split('.', 2)
begin
actual = Base64.urlsafe_decode64(sig_b64)
rescue ArgumentError
return false
end
matched = secrets.any? do |secret|
expected = OpenSSL::HMAC.digest('SHA256', secret, payload_b64)
expected.bytesize == actual.bytesize &&
OpenSSL.fixed_length_secure_compare(expected, actual)
end
return false unless matched
begin
payload = JSON.parse(Base64.urlsafe_decode64(payload_b64))
rescue JSON::ParserError, ArgumentError
return false
end
return false if payload['exp'].to_i < Time.now.to_i
return false if payload['sk'] != site_key
true
end
# Rails example
SECRETS = ENV.fetch('CAPTCHA_SECRET_KEYS', '').split(',').map(&:strip).reject(&:empty?)
class ContactsController < ApplicationController
def create
ok = verify_captcha(
params[:captcha_attestation].to_s,
SECRETS,
ENV.fetch('CAPTCHA_SITE_KEY'),
)
return render(plain: 'Captcha failed.', status: :unprocessable_entity) unless ok
# proceed with the submission
end
end
OpenSSL.fixed_length_secure_compare is the constant-time comparator on Ruby
2.7+. On older runtimes use Rack::Utils.secure_compare from the Rack gem —
never plain == on HMAC bytes, which leaks length information byte-by-byte.
Java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public final class CaptchaVerifier {
private static final ObjectMapper MAPPER = new ObjectMapper();
/** Accepts any key in {@code secrets} — enables zero-downtime rotation. */
public static boolean verify(String attestation, List<String> secrets, String siteKey) {
if (attestation == null || !attestation.contains(".")) return false;
String[] parts = attestation.split("\\.", 2);
String payloadB64 = parts[0];
String sigB64 = parts[1];
byte[] actual;
try {
actual = Base64.getUrlDecoder().decode(sigB64);
} catch (IllegalArgumentException e) {
return false;
}
boolean matched = false;
for (String secret : secrets) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(payloadB64.getBytes(StandardCharsets.UTF_8));
if (MessageDigest.isEqual(expected, actual)) {
matched = true;
break;
}
} catch (Exception e) {
return false;
}
}
if (!matched) return false;
try {
byte[] raw = Base64.getUrlDecoder().decode(payloadB64);
JsonNode payload = MAPPER.readTree(raw);
long exp = payload.path("exp").asLong(0);
if (exp < System.currentTimeMillis() / 1000L) return false;
String sk = payload.path("sk").asText("");
if (!sk.equals(siteKey)) return false;
} catch (Exception e) {
return false;
}
return true;
}
}
java.util.Base64.getUrlDecoder() handles base64url alphabet and absent
padding natively. MessageDigest.isEqual(byte[], byte[]) is documented as
timing-safe since Java 6 — never use Arrays.equals(byte[], byte[]) for HMAC
comparison.
Quick test with curl
To see the raw attestation shape, run through the flow manually. The example below uses
a trivial solver loop in bash for completeness — in production the browser Web Worker
does this for you. Substitute YOUR_SITE_KEY and YOUR_SECRET_KEY
with your project's real values from the dashboard.
#!/usr/bin/env bash
set -euo pipefail
SITE_KEY='YOUR_SITE_KEY'
SECRET_KEY='YOUR_SECRET_KEY'
# ── 1. Request a challenge ────────────────────────────────────────────────
CHALLENGE=$(curl -s -X POST https://captchaapi.eu/api/v1/captcha/challenge \
-H 'Content-Type: application/json' \
-d "{\"site_key\":\"${SITE_KEY}\"}")
TOKEN=$(echo "$CHALLENGE" | jq -r .token)
TARGET=$(echo "$CHALLENGE" | jq -r .target)
echo "token = $TOKEN"
echo "target = $TARGET (first 8 hex chars of sha256(token+nonce) must be ≤ this)"
# ── 2. Find a nonce (brute-force solver — for testing only) ───────────────
# In production the browser does this in a Web Worker. The bash version
# below forks `sha256sum` per nonce, so it runs at roughly 500 hashes/s
# on an M-series MacBook — expect 5 s on the easy end of the Free-tier
# difficulty range and up to ~2 minutes on the hard end. Swap in the
# Python solver if you want sub-second turnaround during development.
NONCE=0
while :; do
HASH=$(printf '%s%s' "$TOKEN" "$NONCE" | sha256sum | cut -c1-8)
HASH_DEC=$((16#$HASH))
if [ "$HASH_DEC" -le "$TARGET" ]; then break; fi
NONCE=$((NONCE + 1))
done
echo "solution = $NONCE (after $((NONCE + 1)) attempts)"
# Faster alternative — Python (~50 ms vs the bash loop's seconds-to-minutes):
# NONCE=$(python3 -c "
# import hashlib
# token, target, n = '$TOKEN', $TARGET, 0
# while int(hashlib.sha256(f'{token}{n}'.encode()).hexdigest()[:8], 16) > target:
# n += 1
# print(n)")
# ── 3. Redeem the solution for an attestation ────────────────────────────
VERIFY=$(curl -s -X POST https://captchaapi.eu/api/v1/captcha/verify \
-H 'Content-Type: application/json' \
-d "{\"token\":\"${TOKEN}\",\"solution\":\"${NONCE}\"}")
ATTESTATION=$(echo "$VERIFY" | jq -r .attestation)
echo "attestation = $ATTESTATION"
# ── 4. Verify the attestation locally (this is what your backend does) ───
PAYLOAD_B64="${ATTESTATION%.*}"
SIG_B64="${ATTESTATION##*.}"
# Recompute HMAC-SHA256 over the base64url payload string (NOT the JSON).
EXPECTED_SIG_B64=$(printf '%s' "$PAYLOAD_B64" \
| openssl dgst -sha256 -hmac "$SECRET_KEY" -binary \
| basenc --base64url \
| tr -d '=')
if [ "$EXPECTED_SIG_B64" = "$SIG_B64" ]; then
echo "✓ HMAC valid"
else
echo "✗ HMAC mismatch"; exit 1
fi
# ── 5. Decode the payload to inspect it ──────────────────────────────────
# `basenc` is GNU coreutils 8.31+ — already on most Linux distros, but on
# macOS install via `brew install coreutils` (it lands as `gbasenc`) or
# swap the line for: `python3 -c "import base64,sys,json; print(json.dumps(json.loads(base64.urlsafe_b64decode(sys.stdin.read()+'==')), indent=2))"`
PADDED="$PAYLOAD_B64$(printf '%.0s=' $(seq 1 $((4 - ${#PAYLOAD_B64} % 4)) ))"
echo "$PADDED" | basenc -d --base64url | jq .
# {
# "sk": "YOUR_SITE_KEY",
# "iat": 1765456789,
# "exp": 1765457089,
# "jti": "01J9F3Z0Y5K7Q8…",
# "ol": false
# }
The shell-level HMAC compare on line 5 above uses plain = — that is
fine for ad-hoc curl debugging, but in real backend code always reach
for the constant-time comparator listed in the language samples above
(hash_equals, crypto.timingSafeEqual, etc.). The shell
comparison is timing-leaky; production HMAC validation must not be.
Choosing an attestation TTL
Each project sets an attestation lifetime in the dashboard — the
exp timestamp baked into every attestation it issues. Default is 5 minutes,
configurable from 1 minute to 10 minutes. Pick the shortest value that still accommodates
your form's realistic fill time.
| TTL | Recommended for | jti replay cache |
|---|---|---|
| 1 minute | Login, password reset, payment confirmation, admin actions — forms with bounded fill time (under ~30 s realistic) | Mandatory |
| 2 minutes | Signup with email verification | Recommended |
| 5 minutes (default) | Contact forms, newsletter signup, comments, reviews | Recommended |
| 10 minutes | Multi-step forms, long surveys, kiosk and assisted-form scenarios | Recommended |
A shorter TTL narrows the window in which a stolen or shared attestation can be replayed
without your backend ever noticing. Combined with
single-use jti caching (mandatory at this
TTL, per the table above), a 1-minute TTL means an attacker has 60 seconds to use a
captured attestation exactly once before it's both expired and burned.
Different forms need different TTLs? Create one project per surface — login gets a 1-minute site key, contact form gets the default 5-minute key. The keys live independently, so you can tune each without affecting the others. Free tier ships with one project; if you need separate TTLs, the lowest paid tier (Starter) covers three.
Required for production: single-use replay protection
The HMAC + exp check above tells you "this attestation is genuine and fresh."
It does not tell you "this attestation hasn't been used before." A bot that
solves the PoW once can distribute the same attestation to many other bots — without
server-side dedup, every one of them passes verification within the exp window.
Closing that gap is one cache call per submit. The pattern is the same in every language:
atomically claim jti with a TTL of exp - now(); if the claim was
already taken, the attestation is a replay.
Use atomic check-and-set. A naive "check if exists, then set" pattern has a race: two parallel form submissions with the same attestation can both pass the existence check before either writes. Use your cache library's atomic primitive —SET NXin raw Redis,Cache::addin Laravel,SetNXin go-redis,nx=Truein redis-py. The examples below all use the atomic form.
Laravel-specific:Cache::addis atomic only on the Redis, Memcached, and DynamoDB drivers. Laravel 11+ ships withdatabaseas the default cache store, which implementsadd()as read-then-write under the hood — same race window as a manualhas() + put(). If yourCACHE_STOREisdatabaseorfile, either switch to Redis for jti dedup, or wrap the claim inCache::lock()explicitly.
Reminder: the
captchaapi/laravel
package handles this automatically. The hand-rolled pattern below documents what happens
under the hood.
Laravel
// After the main signature + exp + sk checks:
use Illuminate\Support\Facades\Cache;
$jti = $payload['jti'] ?? null;
$remaining = ($payload['exp'] ?? 0) - time();
if (! is_string($jti) || $remaining <= 0) {
$fail('Captcha verification failed.');
return;
}
if (! Cache::add("captcha:jti:{$jti}", 1, $remaining)) {
$fail('Captcha attestation already used.');
return;
}
Plain PHP (phpredis or Predis)
$jti = $payload['jti'] ?? null;
$remaining = ($payload['exp'] ?? 0) - time();
if (! is_string($jti) || $remaining <= 0) {
return false;
}
if (! $redis->set("captcha:jti:{$jti}", 1, ['NX', 'EX' => $remaining])) {
return false; // already used
}
Node.js (ioredis)
const remaining = payload.exp - Math.floor(Date.now() / 1000);
const claimed = await redis.set(
`captcha:jti:${payload.jti}`,
1,
'NX',
'EX',
remaining,
);
if (claimed !== 'OK') {
throw new Error('Captcha attestation already used');
}
Python (redis-py)
import time
remaining = payload['exp'] - int(time.time())
if not redis_client.set(f"captcha:jti:{payload['jti']}", 1, nx=True, ex=remaining):
raise ValueError('Captcha attestation already used')
Go (go-redis)
remaining := time.Until(time.Unix(payload.Exp, 0))
claimed, err := rdb.SetNX(ctx, "captcha:jti:"+payload.Jti, 1, remaining).Result()
if err != nil {
return err
}
if !claimed {
return errors.New("captcha attestation already used")
}
Ruby (redis-rb)
remaining = payload['exp'] - Time.now.to_i
unless redis.set("captcha:jti:#{payload['jti']}", 1, nx: true, ex: remaining)
raise 'Captcha attestation already used'
end
Java (Jedis)
long remaining = payload.exp - Instant.now().getEpochSecond();
SetParams params = SetParams.setParams().nx().ex(remaining);
String result = jedis.set("captcha:jti:" + payload.jti, "1", params);
if (!"OK".equals(result)) {
throw new IllegalStateException("Captcha attestation already used");
}
No cache available? Without dedup, the replay window equals the attestation TTL (default 5 minutes). Whether that's acceptable depends on what's behind the form: a contact form usually survives it; signup, payment, and auth flows should not.
Putting it together: verify + replay protection
The standalone snippets above show the two pieces in isolation. Below is each language's verifier with the replay claim wired in — one function, one boolean answer, copy-paste ready. Pass your Redis client (or use the Cache facade for Laravel) and you have a single check that catches HMAC failures, expiry, wrong site key, and replays.
Laravel
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Cache;
class CaptchaAttestation implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! str_contains($value, '.')) {
$fail('Captcha verification failed.');
return;
}
[$payloadB64, $sigB64] = explode('.', $value, 2);
$actual = base64_decode(strtr($sigB64, '-_', '+/'), strict: true);
if ($actual === false) {
$fail('Captcha verification failed.');
return;
}
$matched = false;
foreach ((array) config('services.captcha.secret_keys') as $key) {
$expected = hash_hmac('sha256', $payloadB64, $key, binary: true);
if (hash_equals($expected, $actual)) {
$matched = true;
break;
}
}
if (! $matched) {
$fail('Captcha verification failed.');
return;
}
$rawPayload = base64_decode(strtr($payloadB64, '-_', '+/'), strict: true);
if ($rawPayload === false) {
$fail('Captcha verification failed.');
return;
}
$payload = json_decode($rawPayload, true);
if (! is_array($payload)
|| ($payload['exp'] ?? 0) < time()
|| ($payload['sk'] ?? '') !== config('services.captcha.site_key')) {
$fail('Captcha verification failed.');
return;
}
$jti = $payload['jti'] ?? null;
$remaining = ($payload['exp'] ?? 0) - time();
if (! is_string($jti) || $remaining <= 0) {
$fail('Captcha verification failed.');
return;
}
if (! Cache::add("captcha:jti:{$jti}", 1, $remaining)) {
$fail('Captcha attestation already used.');
return;
}
}
}
Plain PHP
<?php
/**
* @param list<string> $secrets Accept any key in this list — enables zero-downtime rotation.
*/
function verifyCaptcha(string $attestation, array $secrets, string $siteKey, Redis $redis): bool
{
if (! str_contains($attestation, '.')) return false;
[$payloadB64, $sigB64] = explode('.', $attestation, 2);
$actual = base64_decode(strtr($sigB64, '-_', '+/'), strict: true);
if ($actual === false) return false;
$matched = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $payloadB64, $secret, binary: true);
if (hash_equals($expected, $actual)) {
$matched = true;
break;
}
}
if (! $matched) return false;
$rawPayload = base64_decode(strtr($payloadB64, '-_', '+/'), strict: true);
if ($rawPayload === false) return false;
$payload = json_decode($rawPayload, true);
if (! is_array($payload)) return false;
if (($payload['exp'] ?? 0) < time()) return false;
if (($payload['sk'] ?? '') !== $siteKey) return false;
$jti = $payload['jti'] ?? null;
$remaining = ($payload['exp'] ?? 0) - time();
if (! is_string($jti) || $remaining <= 0) return false;
if (! $redis->set("captcha:jti:{$jti}", 1, ['NX', 'EX' => $remaining])) {
return false; // already used
}
return true;
}
$secrets = array_filter(array_map('trim', explode(',', getenv('CAPTCHA_SECRET_KEYS') ?: '')));
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$ok = verifyCaptcha(
$_POST['captcha_attestation'] ?? '',
$secrets,
getenv('CAPTCHA_SITE_KEY'),
$redis,
);
if (! $ok) {
http_response_code(422);
exit('Captcha failed.');
}
Node.js
import crypto from 'node:crypto';
function base64UrlDecode(str) {
const pad = str.length % 4;
const padded = pad ? str + '='.repeat(4 - pad) : str;
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
// `secrets` is an array — any matching key accepts the attestation. Enables zero-downtime rotation.
export async function verifyCaptcha(attestation, secrets, siteKey, redis) {
if (typeof attestation !== 'string' || !attestation.includes('.')) return false;
const [payloadB64, sigB64] = attestation.split('.', 2);
const actual = base64UrlDecode(sigB64);
const matched = secrets.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(payloadB64).digest();
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
});
if (!matched) return false;
let payload;
try { payload = JSON.parse(base64UrlDecode(payloadB64).toString('utf8')); }
catch { return false; }
if (payload.exp < Math.floor(Date.now() / 1000)) return false;
if (payload.sk !== siteKey) return false;
const remaining = payload.exp - Math.floor(Date.now() / 1000);
if (typeof payload.jti !== 'string' || remaining <= 0) return false;
const claimed = await redis.set(`captcha:jti:${payload.jti}`, 1, 'NX', 'EX', remaining);
if (claimed !== 'OK') return false;
return true;
}
// Express example
import Redis from 'ioredis';
const redis = new Redis();
const secrets = (process.env.CAPTCHA_SECRET_KEYS || '').split(',').map(s => s.trim()).filter(Boolean);
app.post('/contact', async (req, res) => {
const ok = await verifyCaptcha(
req.body.captcha_attestation,
secrets,
process.env.CAPTCHA_SITE_KEY,
redis,
);
if (!ok) return res.status(422).send('Captcha failed.');
// proceed with the submission
});
Python
import base64
import hmac
import hashlib
import json
import os
import time
import redis as redis_lib
def _b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def verify_captcha(attestation: str, secrets: list[str], site_key: str, redis_client) -> bool:
"""secrets is a list — any matching key accepts the attestation (enables zero-downtime rotation)."""
if "." not in attestation:
return False
payload_b64, sig_b64 = attestation.split(".", 1)
try:
actual = _b64url_decode(sig_b64)
except Exception:
return False
matched = False
for secret in secrets:
expected = hmac.new(secret.encode(), payload_b64.encode(), hashlib.sha256).digest()
if hmac.compare_digest(expected, actual):
matched = True
break
if not matched:
return False
try:
payload = json.loads(_b64url_decode(payload_b64))
except Exception:
return False
if payload.get("exp", 0) < time.time():
return False
if payload.get("sk") != site_key:
return False
jti = payload.get("jti")
remaining = payload.get("exp", 0) - int(time.time())
if not isinstance(jti, str) or remaining <= 0:
return False
if not redis_client.set(f"captcha:jti:{jti}", 1, nx=True, ex=remaining):
return False # already used
return True
# Flask example
redis_client = redis_lib.Redis(host="127.0.0.1", port=6379)
secrets = [s.strip() for s in os.environ.get("CAPTCHA_SECRET_KEYS", "").split(",") if s.strip()]
@app.post("/contact")
def contact():
ok = verify_captcha(
request.form.get("captcha_attestation", ""),
secrets,
os.environ["CAPTCHA_SITE_KEY"],
redis_client,
)
if not ok:
return "Captcha failed.", 422
# proceed
Go
Note the signature gains context.Context compared to the verify-only version
above — go-redis's idiomatic API takes a context for cancellation and deadline propagation,
and the same context flows naturally to SetNX. Other languages don't need this
because their Redis clients don't expose cancellation through their normal call shape.
package captcha
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
type payload struct {
Sk string `json:"sk"`
Exp int64 `json:"exp"`
Jti string `json:"jti"`
Ol bool `json:"ol"`
Iat int64 `json:"iat"`
}
// Verify accepts any key in `secrets` — enables zero-downtime rotation.
func Verify(ctx context.Context, attestation string, secrets []string, siteKey string, rdb *redis.Client) bool {
parts := strings.SplitN(attestation, ".", 2)
if len(parts) != 2 {
return false
}
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false
}
matched := false
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts[0]))
if hmac.Equal(mac.Sum(nil), actual) {
matched = true
break
}
}
if !matched {
return false
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var p payload
if err := json.Unmarshal(raw, &p); err != nil {
return false
}
if p.Exp < time.Now().Unix() {
return false
}
if p.Sk != siteKey {
return false
}
remaining := time.Until(time.Unix(p.Exp, 0))
if p.Jti == "" || remaining <= 0 {
return false
}
claimed, err := rdb.SetNX(ctx, "captcha:jti:"+p.Jti, 1, remaining).Result()
if err != nil || !claimed {
return false
}
return true
}
Ruby
require 'base64'
require 'json'
require 'openssl'
require 'redis'
# `secrets` is an Array — any matching key accepts the attestation. Enables zero-downtime rotation.
def verify_captcha(attestation, secrets, site_key, redis)
return false unless attestation.is_a?(String) && attestation.include?('.')
return false unless secrets.is_a?(Array)
payload_b64, sig_b64 = attestation.split('.', 2)
begin
actual = Base64.urlsafe_decode64(sig_b64)
rescue ArgumentError
return false
end
matched = secrets.any? do |secret|
expected = OpenSSL::HMAC.digest('SHA256', secret, payload_b64)
expected.bytesize == actual.bytesize &&
OpenSSL.fixed_length_secure_compare(expected, actual)
end
return false unless matched
begin
payload = JSON.parse(Base64.urlsafe_decode64(payload_b64))
rescue JSON::ParserError, ArgumentError
return false
end
return false if payload['exp'].to_i < Time.now.to_i
return false if payload['sk'] != site_key
jti = payload['jti']
remaining = payload['exp'].to_i - Time.now.to_i
return false unless jti.is_a?(String) && remaining.positive?
return false unless redis.set("captcha:jti:#{jti}", 1, nx: true, ex: remaining)
true
end
Java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
public final class CaptchaVerifier {
private static final ObjectMapper MAPPER = new ObjectMapper();
/** Accepts any key in {@code secrets} — enables zero-downtime rotation. */
public static boolean verify(String attestation, List<String> secrets, String siteKey, Jedis jedis) {
if (attestation == null || !attestation.contains(".")) return false;
String[] parts = attestation.split("\\.", 2);
String payloadB64 = parts[0];
String sigB64 = parts[1];
byte[] actual;
try {
actual = Base64.getUrlDecoder().decode(sigB64);
} catch (IllegalArgumentException e) {
return false;
}
boolean matched = false;
for (String secret : secrets) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(payloadB64.getBytes(StandardCharsets.UTF_8));
if (MessageDigest.isEqual(expected, actual)) {
matched = true;
break;
}
} catch (Exception e) {
return false;
}
}
if (!matched) return false;
long exp;
String jti;
try {
byte[] raw = Base64.getUrlDecoder().decode(payloadB64);
JsonNode payload = MAPPER.readTree(raw);
exp = payload.path("exp").asLong(0);
if (exp < Instant.now().getEpochSecond()) return false;
String sk = payload.path("sk").asText("");
if (!sk.equals(siteKey)) return false;
JsonNode jtiNode = payload.path("jti");
if (jtiNode.isMissingNode() || !jtiNode.isTextual()) return false;
jti = jtiNode.asText();
} catch (Exception e) {
return false;
}
long remaining = exp - Instant.now().getEpochSecond();
if (remaining <= 0) return false;
SetParams params = SetParams.setParams().nx().ex(remaining);
String result = jedis.set("captcha:jti:" + jti, "1", params);
if (!"OK".equals(result)) return false;
return true;
}
}
Rotating the secret key
Rotation is explicit and driven by you — the backend never swaps the signing key out from under a running deployment. The flow has four steps:
- In the project dashboard, click Rotate secret key. This generates a new key and stores it as pending. The backend keeps signing attestations with the current key.
-
Copy the pending key and add it to your
CAPTCHA_SECRET_KEYSalongside the current one (comma-separated). Deploy. - Once deployed, click Activate pending key in the dashboard. The backend now signs with the new key; your app accepts both during the handover.
- After confirming the new key works, remove the old one from your config on the next deploy.
For suspected-compromise scenarios, use Revoke immediately. That replaces the key in one step and skips the pending phase — attestations will fail until you deploy the new key, so accept the brief outage in exchange for cutting off the compromised key.
Implementation notes
- Use constant-time comparison.
hash_equals,crypto.timingSafeEqual,hmac.Equal,hmac.compare_digest— never===. - Base64url, not standard base64. The alphabet is
-_instead of+/and there's no=padding. Most standard-library base64 decoders accept base64url if you translate the alphabet and re-add padding. - Don't leak missing attestations as successes. If the hidden input is absent (the widget never loaded, or the attacker stripped it), treat the submission as failed.
- Reject stale attestations. Always check
exp. The payload is unencrypted, so a very old attestation with a forged futureexpwould fail signature verification — but checkexpanyway as defence in depth. - Verify the site key in the payload. A customer running multiple projects on the same app server could theoretically mix up secret/site keys; the
skcheck catches that misconfiguration.