The goal has not changed: one solved challenge may pass exactly one form submission. What changed is where I enforce that single use. It used to be local in Laravel via jti and Cache::add. Now the captchaapi.eu server holds it and the integrator only makes one call to /verify.
This article is about why I rewrote that model and what went away with it.
How it worked before
The PoW captcha issued a signed attestation (a request to /challenge → solving the PoW → a request to /verify → a signed attestation) and Laravel verified it locally as an HMAC check, with no HTTP call. Fast, but that attestation is a bearer token - whoever holds it can send it again until exp expires. That is jti replay.
I solved it with a single-use token: every attestation carried a jti claim, the server remembered used jti values and rejected the second attempt. Atomically via:
Cache::add($key, true, $ttl);
// Redis: SET key 1 NX EX ttl
The first request goes through, the second returns false and that is a replay. It worked. So why change it?
Why I moved it to the server
Three reasons, all about security.
1. Replay protection depended on the integrator's configuration.
Cache::add is atomic only on a shared driver (Redis, Memcached, database). When the integrator had the array or file driver, the protection did not work at all.
2. Single use should be owned by the authority, not the client.
The server sees all requests across all integrators. One SETNX on its side and single use is guaranteed for everyone, regardless of their stack. A local check can never give you that certainty.
3. This is not the integrator's responsibility.
No local replay table, no cache_prefix, no jti parsing, no cleanup. The integrator sends a value and gets a yes/no.
What it looks like now
The widget adds a hidden captchaapi_response field to the form. The backend sends it to /verify with the project secret as a Bearer token:
$response = Http::asJson()
->withToken($secret)
->post('captchaapi.eu/api/v1/verify', ['response' => $value]);
if ($response->json('success') === true) {
// passes
}
Single-use and unique verification now belongs to the server: the first /verify claims the token (SETNX), the second attempt with the same token returns a failure. The integrator does not need to know anything about replay.
More certainty for the same price: instead of a local HMAC check there is one server-to-server call on every submission. But that is no change from the previous implementation, where the /verify call followed right after the PoW challenge was issued.
What stayed: Fortify validates twice
This trap survived the migration. Fortify runs the validation rules twice in one request. But the token is single-use on the server - the second pass sends /verify with the same value, hits an already claimed token and rejects a user who solved the captcha correctly.
The fix is the same as before: memoization within the request, separate from the verification.
$memoKey = $this->memoKey($value);
if ($this->isMemoized($memoKey)) {
return; // already verified in this request
}
// … /verify call …
$this->memoize($memoKey); // after success
The memo:
- lives in
request()->attributes - disappears at the end of the request
- does not affect verification in the next request
The first pass calls /verify, the second is served from memory. Between requests the token stays single-use where it should be - on the server.
Note: this was a real bug. When I rewrote the package for server-side verify, I accidentally dropped the memoization. The integrators' tests did not catch it because they use a fake bypass. It only failed in production on Fortify login. Bringing the memoization back was the fix.
When the server is unavailable
The local HMAC never "went down", it was computed offline. A server-to-server call can fail, so it needs a fail policy:
ConnectionExceptionor 5xx → thefail_openconfig decides.truelets the form through (availability > letting a bot in),falserejects.- "invalid" from the server → always rejects.
TL;DR
- The goal is still "one attestation, one submit". Only who implements it changed.
- Local
jti+Cache::addworked, but single use depended on the integrator's cache driver. - Server-side verify moves single use to the authority: one
/verify, the token is claimed, the server handles replay. - Cost: an HTTP call on every submission. The same cost as before. So the cost is the same.
- Double validation (Fortify) is handled by memoization in
request()->attributes. - When the server is down,
fail_opendecides (careful, it has no effect on a rejection from the server, that still blocks).