azymuthia / turnstile-bundle
Strict Cloudflare Turnstile verification for Symfony firewalls and forms, refusing whenever it cannot verify.
Package info
github.com/azymuthia/turnstile-bundle
Type:symfony-bundle
pkg:composer/azymuthia/turnstile-bundle
Requires
- php: >=8.3
- psr/log: ^2.0 || ^3.0
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/form: ^7.4 || ^8.0
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/http-client: ^7.4 || ^8.0
- symfony/http-client-contracts: ^3.4
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/options-resolver: ^7.4 || ^8.0
- symfony/security-bundle: ^7.4 || ^8.0
- symfony/security-core: ^7.4 || ^8.0
- symfony/security-http: ^7.4 || ^8.0
- symfony/twig-bundle: ^7.4 || ^8.0
- symfony/uid: ^7.4 || ^8.0
- symfony/validator: ^7.4 || ^8.0
- twig/twig: ^3.12
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.4
- symfony/browser-kit: ^7.4 || ^8.0
- symfony/css-selector: ^7.4 || ^8.0
- symfony/rate-limiter: ^7.4 || ^8.0
- symfony/translation: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Strict Cloudflare Turnstile verification for Symfony firewalls and forms.
- Refuses whenever it cannot verify. A timeout, a transport error, a non-2xx answer or a malformed one means the check failed, never that it passed.
- Checks more than
success. The token must have been issued on the request host, or on a hostname you allow, for the action you expect. The visitor's IP is sent to Cloudflare, and the request is bounded to 5 seconds idle and 10 seconds in total. - Plugs into the firewall.
form_loginkeeps its CSRF check, login throttling and user checker. The Turnstile check runs after CSRF and throttling and before the user is loaded or the password hashed, so a bot costs no database lookup or password hash. - Has no off switch. Tests run with Turnstile on, against a mock of Cloudflare's answer.
Requires PHP 8.3 or later and Symfony 7.4 or 8.
Installation
composer require azymuthia/turnstile-bundle
Add the bundle to config/bundles.php if Flex did not:
return [ // ... Azymuthia\TurnstileBundle\AzymuthiaTurnstileBundle::class => ['all' => true], ];
Create a widget in the Cloudflare dashboard (Turnstile → Add widget). Use one widget per application and environment, and Managed mode unless you have a reason to pick another. Then configure the bundle:
# config/packages/azymuthia_turnstile.yaml azymuthia_turnstile: site_key: '%env(CLOUDFLARE_TURNSTILE_SITE_KEY)%' secret_key: '%env(CLOUDFLARE_TURNSTILE_SECRET_KEY)%' # Hostnames a token may be issued on besides the request host: a list, or an env var. allowed_hostnames: '%env(csv:CLOUDFLARE_TURNSTILE_ALLOWED_HOSTNAMES)%' # The id of a service implementing Azymuthia\TurnstileBundle\VisitorIp\VisitorIpResolver. # Defaults to Request::getClientIp(). visitor_ip_resolver: ~ # Firewalls whose form_login requires a Turnstile token. firewalls: main: { action: login, field: cf-turnstile-response }
The visitor's IP behind Cloudflare
Behind Cloudflare or another proxy, Request::getClientIp() is the proxy's address unless the proxy is
a trusted proxy. If it is not, implement
VisitorIpResolver and name the service in visitor_ip_resolver:
use Azymuthia\TurnstileBundle\VisitorIp\VisitorIpResolver; use Symfony\Component\HttpFoundation\Request; final class CloudflareVisitorIp implements VisitorIpResolver { public function resolve(Request $request): ?string { return $request->headers->get('CF-Connecting-IP') ?? $request->getClientIp(); } }
Login (form_login)
List the firewall under firewalls and render the widget inside the login form:
<form method="post" action="{{ path('login') }}"> {# _username, _password, _csrf_token ... #} {{ azymuthia_turnstile_widget('login') }} <button type="submit">Log in</button> </form>
azymuthia_turnstile_widget(action, options) accepts field_name (default cf-turnstile-response),
theme (auto, light or dark; default auto), size (flexible, normal or compact; default
flexible) and language (default: the request locale). The action must match the firewall's action,
and the field name must match its field.
A refused login throws Azymuthia\TurnstileBundle\Security\TurnstileAuthenticationException. Its message
key, Verification failed. Refresh the page and try again., is translated in the security domain and
is the same whether the check failed or Cloudflare could not be reached. getErrorCodes() tells the two
apart, for a LoginFailureEvent listener to log:
use Azymuthia\TurnstileBundle\Security\TurnstileAuthenticationException; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Security\Http\Event\LoginFailureEvent; #[AsEventListener] final class LogTurnstileFailures { public function __invoke(LoginFailureEvent $event): void { $exception = $event->getException(); if ($exception instanceof TurnstileAuthenticationException) { // $exception->getErrorCodes(), e.g. ['timeout-or-duplicate'] or ['unreachable'] } } }
A Turnstile failure counts towards login throttling like any other failed login.
Only form_login is covered: remember-me, HTTP basic and other authenticators on the same firewall are
left alone. A custom authenticator can require a token itself, on any firewall, by adding a badge to
its passport:
use Azymuthia\TurnstileBundle\Security\TurnstileBadge; return new Passport( new UserBadge($username), new PasswordCredentials($password), [new TurnstileBadge($request->request->getString('cf-turnstile-response'), 'login')], );
Forms
use Azymuthia\TurnstileBundle\Form\TurnstileType; $builder->add('turnstile', TurnstileType::class, [ 'turnstile_action' => 'password_reset', ]);
The field renders the widget and validates the token with the Turnstile constraint. The violation
message is translated in the validators domain. The field is not mapped. Options:
| Option | Default | Meaning |
|---|---|---|
turnstile_action |
(required) | The widget's action, which the token must match. |
turnstile_validation |
true |
Validate the token when the form is submitted. |
turnstile_theme |
auto |
auto, light or dark. |
turnstile_size |
flexible |
flexible, normal or compact. |
turnstile_language |
request locale | The widget's language. |
The widget is rendered by the turnstile_widget block, which the bundle adds to twig.form_themes.
Verifying later
Tokens are single-use. If something has to be checked before Turnstile, for example a rate limit, render
the field with 'turnstile_validation' => false and verify the token yourself afterwards:
use Azymuthia\TurnstileBundle\Verifier\TurnstileVerifier; $result = $verifier->verify($form->get('turnstile')->getData(), 'password_reset', $request); if (!$result->isSuccess()) { // $result->getErrorCodes() }
TurnstileVerifier is a public service (also azymuthia_turnstile.verifier).
Error codes
A refused check carries Cloudflare's error codes,
sanitized, or one of these (constants on Azymuthia\TurnstileBundle\Verifier\ErrorCode):
| Code | Meaning |
|---|---|
unreachable |
Siteverify timed out, could not be reached, or answered non-2xx or malformed. |
missing-input-response |
No token was posted. |
invalid-input-response |
The token is longer than any Turnstile token. |
invalid-input-secret |
The secret is empty, or is a dummy secret in prod. |
hostname-mismatch |
The token was issued on another hostname. |
action-mismatch |
The token was issued for another action. |
Tokens and secrets are never logged. The bundle logs on the turnstile channel when Monolog is installed.
Development: Cloudflare's dummy keys
Cloudflare publishes dummy keys
for development. The secrets 1x0000000000000000000000000000000AA (always passes),
2x0000000000000000000000000000000AA (always fails) and 3x0000000000000000000000000000000AA (token
already spent) answer with the hostname localhost and the action test. For these secrets alone the
hostname and action match is skipped, so the dummy site keys work on any development host:
# .env CLOUDFLARE_TURNSTILE_SITE_KEY=1x00000000000000000000AA CLOUDFLARE_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
When kernel.environment is prod, a dummy secret refuses every check and logs at critical. An
always-passing secret in production would silently switch Turnstile off. Set the real keys in
production.
Testing your application
The bundle never calls Cloudflare in tests if you swap its HTTP client, azymuthia_turnstile.http_client,
for a MockHttpClient answering through TurnstileMockResponseFactory:
# config/services.yaml when@test: services: Azymuthia\TurnstileBundle\Test\TurnstileMockResponseFactory: ~ azymuthia_turnstile.http_client: class: Symfony\Component\HttpClient\MockHttpClient arguments: ['@Azymuthia\TurnstileBundle\Test\TurnstileMockResponseFactory']
TurnstileMockResponseFactory::token($action) returns a token that passes for that action. Anything else
is refused with invalid-input-response, as Cloudflare would refuse it:
use Azymuthia\TurnstileBundle\Test\TurnstileMockResponseFactory; $client->submitForm('Log in', [ '_username' => 'alice', '_password' => 'secret', 'cf-turnstile-response' => TurnstileMockResponseFactory::token('login'), ]);
The response's hostname is localhost, the test client's default. For another host, pass it to the
factory: arguments: ['app.example.com']. Use a secret that is not one of the dummy secrets in test
(e.g. CLOUDFLARE_TURNSTILE_SECRET_KEY=test-secret in .env.test). Otherwise the hostname and action
are not checked, and a token for the wrong action would pass.
Licence
MIT, see LICENSE.