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-*.

Maintainers

Package info

github.com/padosoft/laravel-rebel-bot-protection

pkg:composer/padosoft/laravel-rebel-bot-protection

Statistics

Installs: 4

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

v0.1.0 2026-06-04 00:14 UTC

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.

Laravel Rebel

Laravel 12|13 PHP 8.3+ PHPStan max Pest 4 fail closed MIT

Table of contents

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 reCAPTCHA score) reaches the audit metadata; the token and secret never do.
  • Fail closed by default: a provider error returns false. Opt into fail_open consciously.
  • No scoreless accepts (reCAPTCHA v3): a success: true response without a score is 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 least rebel-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.