logo
captchaAPI

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:

  1. Split the attestation at . — payload segment, signature segment.
  2. Recompute HMAC-SHA256 of the payload segment using the secret key. Compare to the signature segment with a constant-time comparison.
  3. Base64url-decode the payload, parse as JSON.
  4. Check exp >= now() and sk == your_site_key.
  5. Required for production: atomically claim jti as single-use with a TTL of exp - 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 is captchaapi/laravel — install with composer require captchaapi/laravel and you get the HMAC verification rule, multi-secret rotation, replay protection, a Blade component for the widget, and a Livewire trait for native wire:submit forms, 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 NX in raw Redis, Cache::add in Laravel, SetNX in go-redis, nx=True in redis-py. The examples below all use the atomic form.

Laravel-specific: Cache::add is atomic only on the Redis, Memcached, and DynamoDB drivers. Laravel 11+ ships with database as the default cache store, which implements add() as read-then-write under the hood — same race window as a manual has() + put(). If your CACHE_STORE is database or file, either switch to Redis for jti dedup, or wrap the claim in Cache::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:

  1. 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.
  2. Copy the pending key and add it to your CAPTCHA_SECRET_KEYS alongside the current one (comma-separated). Deploy.
  3. Once deployed, click Activate pending key in the dashboard. The backend now signs with the new key; your app accepts both during the handover.
  4. 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 future exp would fail signature verification — but check exp anyway 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 sk check catches that misconfiguration.