padosoft / laravel-rebel-bot-protection
Pluggable anti-bot / CAPTCHA gate for Laravel Rebel: server-side verification of Cloudflare Turnstile, Google reCAPTCHA v3 and hCaptcha tokens, fail-closed by default and fully audited. Part of padosoft/laravel-rebel-*.
Package info
github.com/padosoft/laravel-rebel-bot-protection
pkg:composer/padosoft/laravel-rebel-bot-protection
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- padosoft/laravel-rebel-core: ^0.1
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
This package is auto-updated.
Last update: 2026-06-04 00:15:06 UTC
README
One line of code stands between your login form and the bots. This package is a pluggable anti-bot / CAPTCHA gate: it takes the little token your CAPTCHA widget produces in the browser, checks it server-side with the provider (Cloudflare Turnstile, Google reCAPTCHA v3 or hCaptcha), and answers a single honest question โ "is this a human?". It fails closed (blocks on error), records every decision in your audit trail, and never logs the token or your secret. Part of the
padosoft/laravel-rebel-*suite.
Table of contents
- What it is (and what it is not)
- Quick glossary (one minute)
- Why this package โ the moats
- Rebel Bot Protection vs the alternatives
- How it works (step by step)
- Installation (junior-proof)
- Where to get the keys
- Configuration (every option)
- Usage examples
.env.example- Telemetry & audit
- Security notes
- Testing
- ๐ Vibe coding with batteries included
- License
What it is (and what it is not)
It is the server-side verification half of a CAPTCHA. Your frontend shows a widget
(Turnstile / reCAPTCHA / hCaptcha); the widget hands the browser a short-lived token; you send
that token to your backend; this package POSTs it to the provider's siteverify endpoint with your
secret key and turns the answer into a plain true / false. It implements the core
BotProtection contract, so the rest of Rebel (Channels, email-OTP, step-up) can call it without
knowing or caring which provider you picked.
It is not the widget/frontend (you add that with a few lines of HTML per provider โ see below), and it is not a WAF or a rate-limiter. It answers exactly one question: did a human solve the challenge? Use it as the first gate before you spend money on an SMS or send an OTP.
Depends on padosoft/laravel-rebel-core.
Quick glossary (one minute)
| Term | In plain words |
|---|---|
| CAPTCHA | A challenge that's easy for humans, hard for bots ("are you a robot?"). |
| Token / response | The opaque string the widget gives the browser after a challenge. It's single-use and expires fast. |
siteverify |
The provider's server endpoint where you POST secret + token to get a verdict. |
| Site key | Public key that goes in your frontend widget. |
| Secret key | Private key used server-side here. Never ship it to the browser. |
| Score (reCAPTCHA v3) | A number in 0.0โ1.0: 1.0 = very likely human, 0.0 = very likely bot. You pick the cutoff. |
| Fail closed | If the provider can't be reached, block the request (the safe default). |
| Fail open | If the provider can't be reached, let it through (availability over security). |
| Driver | Which provider backs the gate: turnstile, recaptcha, hcaptcha, or always. |
Why this package โ the moats
| โ | What | In short |
|---|---|---|
| โ โ โ | Provider-agnostic, one contract | Swap Turnstile โ reCAPTCHA โ hCaptcha by changing one env var. Your app code never changes โ it talks to the BotProtection contract. |
| โ โ โ | Fail-closed by default | A provider outage blocks bots instead of waving them through. Flip to fail-open per your risk appetite. |
| โ โ โ | Audited, privacy-first | Every check is recorded in the Rebel audit trail with the IP HMAC'd โ never the raw token, never your secret. |
| โ โ | reCAPTCHA v3 score gate | Enforces a configurable min_score, and refuses a "successful" v3 response that arrives without a score (no silent accepts). |
| โ โ | Offline-testable seam | The provider call sits behind a tiny CaptchaVerifier seam, so the whole suite runs with the HTTP client faked โ zero network. |
| โ | Safe default driver | Ships with always (no-op) so it installs cleanly and you turn on a real provider when you're ready. |
Rebel Bot Protection vs the alternatives
Verifying a CAPTCHA token in a Laravel auth flow, compared:
| Capability | Rebel Bot Protection | Shopify | A single-provider package (e.g. one reCAPTCHA wrapper) | Hand-rolled Http::post() |
|---|---|---|---|---|
| Verify a token server-side | โ | โ | โ | โ |
| Swap provider via one env var (Turnstile / reCAPTCHA / hCaptcha) | โ | โ | โ | โ |
| Fail-closed on provider outage (configurable) | โ | โ (hosted black box) | โ | โ |
reCAPTCHA v3 min_score enforced + no scoreless accepts |
โ | โ | โ | โ |
| Unified audit trail, IP HMAC'd, token never logged | โ | โ | โ | โ |
| Plugs into the rest of the auth stack via a shared contract | โ | โ | โ | โ |
| Offline test seam (no network in CI) | โ | โ | โ | โ |
| Self-hosted / you own the keys & data | โ | โ | โ | โ |
Legend: โ built-in ยท โ partial / manual / per-provider ยท โ not available. A single-provider wrapper is fine until you want to switch vendors or need fail-closed + audit; a hand-rolled
Http::post()skips the error-handling, scoring and telemetry you'll wish you had after the first incident. Shopify is a closed, hosted commerce platform: it runs its own bot defences on its own login and checkout, but exposes none of these primitives to your Laravel app โ you can't choose the provider, set the fail policy, read the audit events, or self-host it. It's a black box for this use case.
How it works (step by step)
[browser] CAPTCHA widget --solves challenge--> token
|
| (your form POSTs the token to your backend)
v
$bot->passes($context, $token)
|
+-- token missing/empty? --> record bot.check.failed --> false (no network call)
+-- secret not configured? --> record + apply fail policy
|
v
[CaptchaVerifier] POST secret + token --> provider /siteverify
|
+-- transport/5xx/bad JSON? --> fail CLOSED (or open) --> record --> result
+-- success + (score >= min_score)? --> record bot.check.passed --> true
+-- otherwise --> record bot.check.failed --> false
Only a real human-solved, server-verified token returns true. Everything else โ empty token,
provider down, low score, bad token โ is an audited false (unless you opt into fail-open for
outages).
Installation (junior-proof)
composer require padosoft/laravel-rebel-bot-protection
The service provider auto-registers (package discovery). Publish the config if you want to tweak it:
php artisan vendor:publish --tag="rebel-bot-protection-config"
Then pick a driver in .env (default is always = no-op, so nothing blocks until you opt in):
REBEL_BOT_DRIVER=turnstile TURNSTILE_SITE_KEY=0x4AAAAAAA... TURNSTILE_SECRET_KEY=0x4AAAAAAA...
That's it โ anything in the suite that depends on the BotProtection contract now routes through
your chosen provider.
Where to get the keys
| Provider | Driver value | Dashboard | What you need |
|---|---|---|---|
| Cloudflare Turnstile | turnstile |
https://dash.cloudflare.com/?to=/:account/turnstile | Site Key (frontend) + Secret Key (server) |
| Google reCAPTCHA v3 | recaptcha |
https://www.google.com/recaptcha/admin (choose v3) | Site Key + Secret Key, then tune min_score |
| hCaptcha | hcaptcha |
https://dashboard.hcaptcha.com | Site Key + Secret Key |
The Site Key goes in your HTML widget; the Secret Key stays in .env and is used here.
Minimal frontend snippets (pair with the matching driver):
<!-- Turnstile --> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div> <!-- the widget injects a hidden input named "cf-turnstile-response" --> <!-- hCaptcha --> <script src="https://js.hcaptcha.com/1/api.js" async defer></script> <div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div> <!-- hidden input: "h-captcha-response" --> <!-- reCAPTCHA v3 (no checkbox; you request a token on submit) --> <script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script> <script> grecaptcha.ready(() => grecaptcha.execute('YOUR_SITE_KEY', { action: 'login' }) .then(token => document.querySelector('#captcha_token').value = token)); </script>
Whatever the field is named, just hand its value to passes() as the $token.
Configuration (every option)
config/rebel-bot-protection.php:
| Key | Env | Default | Effect |
|---|---|---|---|
driver |
REBEL_BOT_DRIVER |
always |
Active gate: turnstile | recaptcha | hcaptcha | always. Unknown value โ always. |
fail_open |
REBEL_BOT_FAIL_OPEN |
false |
On a provider/transport error: false = block (fail closed), true = allow. |
turnstile.site_key |
TURNSTILE_SITE_KEY |
โ | Public key for the frontend widget (not used server-side). |
turnstile.secret |
TURNSTILE_SECRET_KEY |
โ | Server secret used to verify the token. |
turnstile.endpoint |
TURNSTILE_ENDPOINT |
Cloudflare siteverify URL | Override for testing / self-hosting. |
recaptcha.site_key |
RECAPTCHA_SITE_KEY |
โ | Public key for the frontend. |
recaptcha.secret |
RECAPTCHA_SECRET_KEY |
โ | Server secret. |
recaptcha.min_score |
RECAPTCHA_MIN_SCORE |
0.5 |
v3 score cutoff (0.0โ1.0). Below this โ bot. |
recaptcha.endpoint |
RECAPTCHA_ENDPOINT |
Google siteverify URL | Override. |
hcaptcha.site_key |
HCAPTCHA_SITE_KEY |
โ | Public key for the frontend. |
hcaptcha.secret |
HCAPTCHA_SECRET_KEY |
โ | Server secret. |
hcaptcha.endpoint |
HCAPTCHA_ENDPOINT |
hCaptcha siteverify URL | Override. |
Usage examples
1. Resolve the gate and check a token (the common case)
use Padosoft\Rebel\Core\Contracts\BotProtection; use Padosoft\Rebel\Core\Context\SecurityContext; use Padosoft\Rebel\Core\Contracts\KeyedHasher; public function store(Request $request, BotProtection $bot, KeyedHasher $hasher) { $context = SecurityContext::fromRequest($request, $hasher) ->withGuard('customers') ->withPurpose('customer-login'); $token = $request->input('cf-turnstile-response'); // or h-captcha-response / your hidden field if (! $bot->passes($context, $token)) { abort(422, 'Bot check failed. Please retry.'); } // ...human confirmed: proceed to send the OTP / continue login... }
2. In a Rebel Channels send (it's the first gate before you spend a cent)
You don't call it yourself โ Channels asks the BotProtection contract before dispatching an SMS.
Just configure a real driver and the protection is automatic across the suite.
3. Local development / tests: opt out explicitly
REBEL_BOT_DRIVER=always
Every request passes, but the gate still records a bot.check.passed event with
reason: disabled โ so even your "off" state is honest in the audit trail.
4. Trade resilience for security during an incident
# Provider flaky and you'd rather not block real users? Allow on error: REBEL_BOT_FAIL_OPEN=true
Leave it false (the default) for the secure posture: a provider outage blocks suspicious traffic
instead of waving it through.
5. Bind your own driver (advanced)
The contract binding is guarded with ! $app->bound(...), so you can register a custom
BotProtection before this provider boots and it will be used instead โ handy for an enterprise
provider (e.g. Arkose) without forking the package.
.env.example
See .env.example for every variable with comments. The essentials:
REBEL_BOT_DRIVER=turnstile REBEL_BOT_FAIL_OPEN=false TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= RECAPTCHA_SITE_KEY= RECAPTCHA_SECRET_KEY= RECAPTCHA_MIN_SCORE=0.5 HCAPTCHA_SITE_KEY= HCAPTCHA_SECRET_KEY=
Telemetry & audit
Every call to passes() records exactly one event through the core AuditLogger
(persisted to rebel_auth_events, never the session):
| Event type | When | Metadata reason |
|---|---|---|
bot.check.passed |
Human verified (or always-pass driver, or fail-open on error) | verified | disabled | provider_error |
bot.check.failed |
Bot / bad / missing token, low score, or fail-closed error | rejected | missing_token | missing_secret | provider_error |
Each event carries the provider (turnstile / recaptcha / hcaptcha / always), the request
ip_hmac and user_agent_hash (already HMAC'd by the SecurityContext), the guard and
purpose, and โ for reCAPTCHA โ the numeric score. The raw token and your secret are never
recorded.
Security notes
- No token/secret leakage: only a coarse
reason(and reCAPTCHAscore) reaches the audit metadata; the token and secret never do. - Fail closed by default: a provider error returns
false. Opt intofail_openconsciously. - No scoreless accepts (reCAPTCHA v3): a
success: trueresponse without ascoreis treated as a failure โ we never accept a v3 token we can't risk-rank. - Empty token short-circuits: a missing token is an immediate audited failure โ no pointless network round-trip, no information for an attacker.
- Privacy by construction: IP / User-Agent travel as keyed HMACs on the
SecurityContext; this package only ever records those, never cleartext PII.
Testing
composer test # Pest (providers, HTTP seam, driver selection; live suite self-skips) composer phpstan # static analysis, level max composer pint # code style
The offline suite fakes Laravel's HTTP client โ no network. The opt-in live suite
(tests/Live, REBEL_BOT_LIVE=1) hits a real siteverify endpoint using Cloudflare's documented
"always passes" testing keys, and self-skips otherwise.
๐ Vibe coding with batteries included
This package ships AI batteries โ so you (and your AI agent) can extend it correctly on the first try:
CLAUDE.mdโ a concise AI working guide (purpose, conventions, architecture, how to extend, Definition of Done). Plain Markdown, so Claude Code, Cursor, Copilot and Codex all read it.AGENTS.mdโ the agent/workflow contract (branch โ PR โ CI โ tag/release, the gates)..claude/skills/โ invocable skills (at leastrebel-package-dev) encoding the suite's TDD loop, the PHPStan-level-max recipes, the security/telemetry rules, and the release discipline.
Open the repo in your AI editor and just start โ the rules, guardrails and extension recipes come
with it. PRs that follow the shipped CLAUDE.md pass CI (PHPStan max + Pest + Pint) and review the
first time around.
License
License: MIT โ see LICENSE. Part of the padosoft/laravel-rebel suite.
