florentingarnier / spam-protection
Invisible CAPTCHA alternative: honeypot, single-use timed tokens, proof of work, rate limiting, IP reputation and gibberish detection.
Package info
github.com/FlorentinGarnier/spam-protection
pkg:composer/florentingarnier/spam-protection
Requires
- php: ^8.2
- psr/cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0
- symfony/cache: ^5.4 || ^6.4 || ^7.4 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An invisible CAPTCHA alternative for PHP forms. Visitors never solve a puzzle: the form embeds a challenge that the browser solves in the background, and the submission is judged on the server by several independent layers.
- No third-party service, no cookie, no tracking: nothing to declare under the GDPR.
- Framework-agnostic: plain PHP 8.2 with a single dependency, a PSR-6 cache.
- Bots pay an increasing cost: the more they try, the harder the challenge gets.
| Package | Purpose |
|---|---|
| florentingarnier/spam-protection (this one) | The protection itself, for any PHP application |
| florentingarnier/spam-protection-bundle | Symfony integration: form type, JavaScript solver, console command |
| florentingarnier/sylius-spam-protection-plugin | Protects the Sylius shop forms |
How it works
Each submission goes through the following checks, in this order. The first failing check rejects it.
| Layer | Rejection reason | Rejects the submission when |
|---|---|---|
| Honeypot | honeypot_filled |
a field hidden from humans has been filled in |
| Timed token | invalid_timestamp_token |
the signed token is forged, already used, issued for another form, sent less than 3 seconds after the form was displayed, or more than 1 hour after |
| Proof of work | invalid_proof_of_work |
the browser did not provide a valid solution, or reused one |
| Rate limit | rate_limit_exceeded |
the IP address has used up its attempts on this form (20 by default) |
| Content | unreadable_content |
a free-text field is made of random characters, such as dTqLzVbKxWmPfRjN |
Tokens and proof-of-work challenges are single-use: a fresh challenge is issued every time the form is displayed.
Proof of work
The browser looks for a number n such that SHA-256(challenge|n) starts with a given count of zero bits. The
required difficulty grows with the attempts of the IP address on the form:
| Previous attempts | Difficulty (default base of 10 bits) | Indicative cost in a browser |
|---|---|---|
| 0 to 4 | 10 bits | imperceptible |
| 5 to 9 | 14 bits | about 1 second |
| 10 and more | 18 bits | several seconds |
An accepted submission counts as 1 attempt, a rejected one as 3. The counter is kept per form and per IP address, and expires one hour after the last attempt. IPv6 addresses are counted per /64 network, since a single subscriber usually controls a whole /64 and could otherwise change address on every submission.
IP reputation
Addresses of datacenters, VPNs and Tor exit nodes are not blocked, but challenged harder from the very first submission:
| Risk level | Origin | Extra difficulty | Attempt weight | Accepted submissions per hour |
|---|---|---|---|---|
normal |
any other address | 0 bits | × 1 | 20 |
hosting |
datacenter, hosting provider, VPN | 4 bits | × 4 | 5 |
tor |
Tor exit node | 8 bits | × 4 | 5 |
The extra difficulty and the attempt-based increase share a cap of 8 bits, so a challenge always stays solvable in a browser (18 bits with the default base).
Content check
Only words of at least 6 Latin letters are analysed, so references (RX-450B) and acronyms pass. A word looks
random when it has more than one lowercase-to-uppercase transition (iPhone has one, dTqLzV has two) or more
than 5 consecutive consonants. A text is rejected when at least half of its analysed words look random.
Requirements
- PHP 8.2 or later
- A PSR-6 cache pool shared by all your web servers (Redis, Memcached, database…)
- JavaScript in the visitor's browser, to solve the proof of work
Installation
composer require florentingarnier/spam-protection
Usage
Using Symfony? Install the bundle instead: it does all of this for you.
1. Create the service
use FlorentinGarnier\SpamProtection\IpReputation\IpReputation; use FlorentinGarnier\SpamProtection\IpReputation\IpReputationList; use FlorentinGarnier\SpamProtection\SpamProtection; $spamProtection = SpamProtection::create( secret: $appSecret, // long random string, signs the tokens cache: $cachePool, // any PSR-6 pool ipReputation: new IpReputation(new IpReputationList(__DIR__.'/var/ip_reputation.php')), baseDifficulty: 10, // optional maximumAttemptsPerHour: 20, // optional tokenLock: $tokenLock, // recommended, see below );
PSR-6 offers no atomic "add if absent": without a lock, two requests sent at the same instant with the same
token could both be accepted. Implement FlorentinGarnier\SpamProtection\TokenLock with the locking tool you
already use (a Redis SET NX, a database advisory lock…), shared by all your web servers. acquire() must not
wait: it returns false at once when another request holds the lock.
2. Embed a challenge in the form
$challenge = $spamProtection->issueChallenge('contact', $clientIp);
<div hidden aria-hidden="true"> <input type="text" name="fax_number" autocomplete="off" tabindex="-1"> <input type="hidden" name="rendered_at" value="<?= htmlspecialchars($challenge->submissionToken) ?>"> <input type="hidden" name="proof_challenge" value="<?= htmlspecialchars($challenge->proofOfWorkChallenge) ?>" data-difficulty="<?= $challenge->difficulty ?>"> <input type="hidden" name="proof_solution"> </div>
The first argument, the scope, identifies the form: tokens and attempt counters are not shared between
forms. Before the form is sent, your JavaScript must fill proof_solution. The bundle ships a ready-made
solver that
you can adapt.
3. Verify the submission
use FlorentinGarnier\SpamProtection\Submission; $verdict = $spamProtection->verify(new Submission( scope: 'contact', honeypot: $_POST['fax_number'] ?? '', submissionToken: $_POST['rendered_at'] ?? '', proofOfWorkChallenge: $_POST['proof_challenge'] ?? '', proofOfWorkSolution: $_POST['proof_solution'] ?? '', contents: [$_POST['message'] ?? ''], ), $clientIp); if (!$verdict->isAccepted()) { $logger->warning('Spam rejected', [ 'reason' => $verdict->rejectionReason->value, 'ip_risk' => $verdict->ipRiskLevel->value, ]); // Display the form again, with a fresh challenge. }
Rejection reasons are stable strings: you can rely on them in logs and dashboards.
4. Compile the IP lists
IpReputation reads a compiled list of IPv4 ranges. As long as no list exists, every address is considered
normal, so the protection never blocks anyone by mistake. Compile the lists from any plain-text CIDR source:
use FlorentinGarnier\SpamProtection\IpReputation\IpRangeSet; (new IpReputationList(__DIR__.'/var/ip_reputation.php'))->save([ 'hosting' => IpRangeSet::fromCidrs(explode("\n", file_get_contents('https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/datacenter/ipv4.txt'))), 'tor' => IpRangeSet::fromCidrs(explode("\n", file_get_contents('https://check.torproject.org/torbulkexitlist'))), ]);
The file is replaced atomically and loaded through opcache. Ranges are merged and searched by dichotomy, so lists of tens of thousands of entries stay cheap. Refresh them daily. The bundle provides a console command for this.
Testing your own forms
FlorentinGarnier\SpamProtection\Testing\SpamProtectionTestHelper forges backdated tokens and solves the proof
of work, so that your functional tests do not have to wait 3 seconds:
$token = SpamProtectionTestHelper::forgeSubmissionToken($secret, 'contact', time() - 4); $solution = SpamProtectionTestHelper::solveProofOfWork($challenge->proofOfWorkChallenge, $challenge->difficulty);
Use a low baseDifficulty (4, for example) in tests to keep them fast.
Limitations
- The visitor's real IP address matters. Behind a reverse proxy or a load balancer, configure your framework to trust it. Otherwise all visitors share the proxy's address, and the rate limit applies to everyone at once.
- IPv4 only for IP reputation: IPv6 addresses are considered
normal. - Residential proxies are not in any list. They are still slowed down by the proof of work and rate limited.
- Visitors without JavaScript cannot submit a protected form.
- This library stops automated spam. It does not replace input validation, CSRF protection or rate limiting of sensitive operations such as login.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md and the Code of Conduct. Report security issues privately, as described in SECURITY.md.
License
Released under the MIT License.