Turnstile
A Cloudflare Turnstile check (managed mode) for a form: loads Cloudflare's script once, only when it renders, puts the token in a hidden input, follows the Brok theme and survives Livewire morphs and wire:navigate. Renders nothing without a site key.
Preview
{{-- A sign-in form with the check before the submit button. The site key is
Cloudflare's public test key, which always passes; an app sets
TURNSTILE_SITE_KEY and reads it through config('services.turnstile.site_key').
The server verifies cf-turnstile-response with siteverify. --}}
<form method="POST" action="/login" class="flex w-full max-w-sm flex-col gap-4" x-on:submit.prevent>
@csrf
<x-ui.field>
<x-ui.label for="turnstile-email">{{ __('Email') }}</x-ui.label>
<x-ui.input id="turnstile-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</x-ui.field>
<x-ui.turnstile site-key="1x00000000000000000000AA" action="login" />
<x-ui.button type="submit">{{ __('Sign in') }}</x-ui.button>
</form>
Installation
php artisan ui:add turnstile
Note
This component ships an Alpine behavior module at
resources/js/ui/turnstile.js. Import it once from your bundle so it registers on alpine:init:
import './turnstile.js';
Registry contract
php artisan ui:add turnstile
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/turnstile.blade.php -
resources/js/ui/turnstile.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Turnstile (`turnstile`)
A Cloudflare Turnstile check (managed mode) for a form: loads Cloudflare's script once, only when it renders, puts the token in a hidden input, follows the Brok theme and survives Livewire morphs and wire:navigate. Renders nothing without a site key.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add turnstile
```
## Usage
```blade
{{-- A sign-in form with the check before the submit button. The site key is
Cloudflare's public test key, which always passes; an app sets
TURNSTILE_SITE_KEY and reads it through config('services.turnstile.site_key').
The server verifies cf-turnstile-response with siteverify. --}}
<form method="POST" action="/login" class="flex w-full max-w-sm flex-col gap-4" x-on:submit.prevent>
@csrf
<x-ui.field>
<x-ui.label for="turnstile-email">{{ __('Email') }}</x-ui.label>
<x-ui.input id="turnstile-email" type="email" name="email" autocomplete="email" placeholder="[email protected]" />
</x-ui.field>
<x-ui.turnstile site-key="1x00000000000000000000AA" action="login" />
<x-ui.button type="submit">{{ __('Sign in') }}</x-ui.button>
</form>
```
## Props
- `siteKey` (string|null, default `null`) — Public Turnstile site key. Null reads config('services.turnstile.site_key'). Empty renders nothing and loads no script, so local development and tests keep working without a key.
- `action` (string|null, default `null`) — Optional action name (for example login). Siteverify returns it, so the server can check that the token came from this form.
- `theme` (auto|light|dark, default `auto`) — auto follows the active Brok theme (the color-scheme of the element, set by .dark or .light) and renders the widget again after a theme switch until the visitor has passed.
- `size` (flexible|normal|compact, default `flexible`) — flexible fills the form width (at least 300px), normal is 300 by 65px, compact is 150 by 140px. Space for the widget is reserved before it loads.
- `name` (string, default `cf-turnstile-response`) — Name of the hidden input that carries the token. Put wire:model on the component to bind the token to a Livewire property. The element ids derive from it, so give each widget on one page its own name or id.
- `when` (bool, default `true`) — False renders nothing and loads no script, for example until a sign-in has failed a set number of times.
- `language` (string, default `auto`) — Widget language code passed to Turnstile. auto uses the visitor's browser language.
- `label` (string|null, default `null`) — Accessible name of the group around the widget. Defaults to 'Security check'.
## Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- A public form (sign-in, sign-up, password reset, contact) needs bot protection and the app verifies the token server-side with Cloudflare siteverify.
- A form should show the check only when needed, for example sign-in after failed attempts: pass :when="...".
## Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The request is already authenticated and rate limited; a challenge adds friction for no gain, so keep the plain form.
- The server does not call siteverify. The widget alone protects nothing.
## Anti-patterns
- Using freeform entry for a bounded answer set
## Rules
- Use the `<brok:turnstile>` tag (or `<x-ui.turnstile>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/turnstile
- Registry JSON (files, props, contract): https://brokui.dev/r/open/turnstile.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- The compact size for a narrow sidebar form, in a dark island: theme
auto follows the .dark class, so the widget renders dark too. --}}
<div class="dark flex w-48 flex-col gap-4 rounded-lg border border-border bg-background p-4 text-foreground">
<x-ui.turnstile site-key="1x00000000000000000000AA" size="compact" />
</div>
{{-- Every message state, set directly for review. A server error on the
token field shows the same way after a failed submit. --}}
<div class="grid w-full max-w-sm gap-6">
@foreach (['expired', 'timeout', 'error', 'unsupported', 'unavailable'] as $state)
<x-ui.turnstile site-key="1x00000000000000000000AA" :name="'turnstile-'.$state" x-init="clear('{{ $state }}')" />
@endforeach
</div>
Long Content
<div class="w-full max-w-xs">
<x-ui.turnstile
site-key="1x00000000000000000000AA"
label="Security check before we send the password reset link to your work address"
x-init="clear('unavailable')"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| siteKey | string | null | null | Public Turnstile site key. Null reads config('services.turnstile.site_key'). Empty renders nothing and loads no script, so local development and tests keep working without a key. |
| action | string | null | null | Optional action name (for example login). Siteverify returns it, so the server can check that the token came from this form. |
| theme | auto | light | dark | auto | auto follows the active Brok theme (the color-scheme of the element, set by .dark or .light) and renders the widget again after a theme switch until the visitor has passed. |
| size | flexible | normal | compact | flexible | flexible fills the form width (at least 300px), normal is 300 by 65px, compact is 150 by 140px. Space for the widget is reserved before it loads. |
| name | string | cf-turnstile-response | Name of the hidden input that carries the token. Put wire:model on the component to bind the token to a Livewire property. The element ids derive from it, so give each widget on one page its own name or id. |
| when | bool | true | False renders nothing and loads no script, for example until a sign-in has failed a set number of times. |
| language | string | auto | Widget language code passed to Turnstile. auto uses the visitor's browser language. |
| label | string | null | null | Accessible name of the group around the widget. Defaults to 'Security check'. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Loads https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit once per page, async and deferred, only when a widget renders, and renders each widget explicitly. After wire:navigate or a Livewire morph that adds the component, the widget renders again from the loaded script; Alpine's destroy() removes it.
- The token goes into a hidden input (name, default cf-turnstile-response). On expiry, timeout, error and reset the input is cleared, and every change dispatches an input event, so wire:model follows it.
- Events from the root: turnstile-verified { token }, turnstile-cleared { reason } and turnstile-error { code }. A window turnstile-reset event resets every widget on the page; from Livewire use $this->dispatch('turnstile-reset').
- After a failed submit (the error bag is not empty) the widget fetches a fresh token, because a token is valid once. A server error on the token field shows under the widget until the visitor passes again.
- Expired, timeout, error, unsupported and script-load failures clear the token and show a message; timeout, error and load failures add a Try again button.
- Server contract for the host app: POST secret (config('services.turnstile.secret_key')), response (the token) and remoteip to https://challenges.cloudflare.com/turnstile/v0/siteverify with a short timeout, accept only success true (and the expected action and hostname when set), and fail closed on a timeout or HTTP error. Tokens are valid once and for 300 seconds. Brok ships no PHP, so this rule lives in the app.
- Local development: Cloudflare's test site key 1x00000000000000000000AA always passes and 2x00000000000000000000AB always fails (3x00000000000000000000FF forces an interactive challenge); the test secret 1x0000000000000000000000000000000AA always passes and 2x0000000000000000000000000000000AA always fails. Production secrets reject test tokens.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Collect unpredictable freeform information.
Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- A public form (sign-in, sign-up, password reset, contact) needs bot protection and the app verifies the token server-side with Cloudflare siteverify.
- A form should show the check only when needed, for example sign-in after failed attempts: pass :when="...".
Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The request is already authenticated and rate limited; a challenge adds friction for no gain, so keep the plain form.
- The server does not call siteverify. The widget alone protects nothing.
Use instead
- Radio or checkbox for bounded choices
- Select or combobox for known options
Anti-patterns
- Using freeform entry for a bounded answer set
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- The widget sits in a group with an accessible name and is described by the message region, a polite live region that announces expiry and errors.
- The Cloudflare iframe stays in the normal tab order; the component adds no motion of its own.
- Expired and failed checks are stated in text, never by colour alone, and a failed check offers a Try again button.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
{{-- A sign-in form with the check before the submit button. The site key is
Cloudflare's public test key, which always passes; an app sets
TURNSTILE_SITE_KEY and reads it through config('services.turnstile.site_key').
The server verifies cf-turnstile-response with siteverify. --}}
<form method="POST" action="/login" class="flex w-full max-w-sm flex-col gap-4" x-on:submit.prevent>
@csrf
<x-ui.field>
<x-ui.label for="turnstile-email">{{ __('Email') }}</x-ui.label>
<x-ui.input id="turnstile-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</x-ui.field>
<x-ui.turnstile site-key="1x00000000000000000000AA" action="login" />
<x-ui.button type="submit">{{ __('Sign in') }}</x-ui.button>
</form>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:turnstile
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Turnstile: a Cloudflare Turnstile check (managed mode) inside a form. It
loads Cloudflare's api.js once per page, only when a widget renders, and
renders explicitly through Alpine, so it survives Livewire morphs and
wire:navigate. The token goes into a hidden input (`name`, default
cf-turnstile-response) that a normal form submits; put wire:model on the
component to bind it to a Livewire property instead.
With no site key (local development, tests) or `when` false, nothing
renders and no script loads. Verify the token on the server with the
siteverify API; this component only runs the browser side.
--}}
@props([
// Null reads config('services.turnstile.site_key').
'siteKey' => null,
// Optional action name that siteverify returns, for example 'login'.
'action' => null,
// auto follows the active light or dark theme (the color-scheme of this element).
'theme' => 'auto',
'size' => 'flexible',
'name' => 'cf-turnstile-response',
// False renders nothing, for example until a sign-in has failed twice.
'when' => true,
'language' => 'auto',
'label' => null,
])
@php
$siteKey = (string) ($siteKey ?? config('services.turnstile.site_key', ''));
$size = in_array($size, ['normal', 'flexible', 'compact'], true) ? $size : 'flexible';
$theme = in_array($theme, ['auto', 'light', 'dark'], true) ? $theme : 'auto';
$name = filled($name) ? (string) $name : 'cf-turnstile-response';
$id = $attributes->get('id') ?? 'turnstile-'.\Illuminate\Support\Str::slug($name);
$messageId = $id.'-message';
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$serverError = (string) $bag?->first($name);
$model = $attributes->whereStartsWith('wire:model');
$config = [
'siteKey' => $siteKey,
'action' => filled($action) ? (string) $action : null,
'theme' => $theme,
'size' => $size,
'language' => filled($language) ? (string) $language : 'auto',
'serverError' => $serverError,
'messages' => [
'expired' => __('The security check expired. It is running again.'),
'timeout' => __('The security check timed out. Try it again.'),
'error' => __('The security check could not finish. Check your connection and try again.'),
'unsupported' => __('This browser cannot run the security check. Try another browser.'),
'unavailable' => __('The security check could not load. Check your connection or content blocker and try again.'),
],
];
@endphp
@if ($when && $siteKey !== '')
<div
id="{{ $id }}"
data-slot="turnstile"
data-size="{{ $size }}"
role="group"
aria-label="{{ $label ?? __('Security check') }}"
aria-describedby="{{ $messageId }}"
x-data="uiTurnstile(@js($config))"
x-bind:data-state="state"
x-on:turnstile-reset.window="reset()"
{{ $attributes->except(['id'])->whereDoesntStartWith('wire:model')->merge(['class' => 'flex min-w-0 flex-col gap-2']) }}
>
<div
x-ref="widget"
wire:ignore
data-slot="turnstile-widget"
tabindex="-1"
@class([
'min-w-0 outline-none',
'min-h-36' => $size === 'compact',
'min-h-16 w-full' => $size === 'flexible',
'min-h-16' => $size === 'normal',
])
></div>
<input type="hidden" name="{{ $name }}" x-ref="input" {{ $model }} />
<div
id="{{ $messageId }}"
data-slot="turnstile-message"
aria-live="polite"
class="flex min-w-0 flex-wrap items-center gap-x-2 text-sm text-destructive-text"
>
<span class="min-w-0 break-words" x-text="message">{{ $serverError }}</span>
<x-ui.button x-show="retryable" x-cloak variant="link" size="sm" x-on:click="reset(); $nextTick(() => $refs.widget.focus())">{{ __('Try again') }}</x-ui.button>
</div>
@if ($bag?->any())
{{-- A failed submit spent the token. The key changes on every render, so each Livewire morph re-runs this and fetches a fresh token. --}}
<span hidden wire:key="{{ $id }}-refresh-{{ \Illuminate\Support\Str::random(8) }}" x-init="$nextTick(() => refresh(@js($serverError)))"></span>
@endif
<noscript>
<p class="text-sm text-muted-foreground">{{ __('The security check needs JavaScript.') }}</p>
</noscript>
</div>
@endif
/**
* Cloudflare Turnstile, rendered explicitly.
*
* api.js loads once per page (with render=explicit), and only when a widget
* mounts. Each <x-ui.turnstile> renders its own widget into x-ref="widget"
* (wire:ignore, so a Livewire morph leaves the iframe alone) and removes it
* in destroy(), which Alpine runs on wire:navigate and when a morph drops
* the element.
*
* The token lives in the hidden input: set on success, cleared on expiry,
* timeout, error and reset. Each change dispatches an input event, so
* wire:model on the input follows it.
*
* Events: turnstile-verified { token }, turnstile-cleared { reason } and
* turnstile-error { code } bubble from the root. A window turnstile-reset
* event resets every widget on the page, for example after a Livewire
* $this->dispatch('turnstile-reset').
*
* States: loading, ready, verified, expired, timeout, error, unsupported,
* unavailable (the script did not load).
*/
const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
let scriptPromise = null;
function loadTurnstile() {
if (window.turnstile) return Promise.resolve(window.turnstile);
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector('script[data-turnstile-api]');
const script = existing ?? document.createElement('script');
const settle = () =>
window.turnstile ? resolve(window.turnstile) : reject(new Error('turnstile-missing'));
script.addEventListener('load', settle, { once: true });
script.addEventListener('error', () => reject(new Error('turnstile-unavailable')), {
once: true,
});
if (existing) return;
script.src = SCRIPT_URL;
script.async = true;
script.defer = true;
script.dataset.turnstileApi = '';
document.head.appendChild(script);
}).catch((error) => {
// Let a later mount try again, for example after the network returns.
scriptPromise = null;
document.querySelector('script[data-turnstile-api]')?.remove();
throw error;
});
return scriptPromise;
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTurnstile', (config = {}) => ({
state: config.serverError ? 'error' : 'loading',
message: config.serverError ?? '',
widgetId: null,
observer: null,
renderedTheme: null,
destroyed: false,
get retryable() {
return ['timeout', 'error', 'unavailable'].includes(this.state) && !config.serverError;
},
init() {
if (config.theme === 'auto') {
this.observer = new MutationObserver(() => this.followTheme());
this.observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'style'],
});
}
this.mount();
},
destroy() {
this.destroyed = true;
this.observer?.disconnect();
this.observer = null;
this.unmount();
},
resolveTheme() {
if (config.theme !== 'auto') return config.theme;
return getComputedStyle(this.$el).colorScheme.includes('dark') ? 'dark' : 'light';
},
async mount() {
let turnstile;
try {
turnstile = await loadTurnstile();
} catch {
if (!this.destroyed) this.clear('unavailable');
return;
}
if (this.destroyed || this.widgetId !== null) return;
this.renderedTheme = this.resolveTheme();
this.widgetId = turnstile.render(this.$refs.widget, {
sitekey: config.siteKey,
...(config.action ? { action: config.action } : {}),
theme: this.renderedTheme,
size: config.size ?? 'flexible',
language: config.language ?? 'auto',
'response-field': false,
callback: (token) => {
config.serverError = '';
this.setToken(token);
this.show('verified', '');
this.$dispatch('turnstile-verified', { token });
},
'expired-callback': () => this.clear('expired'),
'timeout-callback': () => this.clear('timeout'),
'error-callback': (code) => {
this.clear('error');
this.$dispatch('turnstile-error', { code: String(code ?? '') });
// Handled: Turnstile retries on its own and does not throw.
return true;
},
'unsupported-callback': () => this.clear('unsupported'),
});
if (this.state === 'loading') this.show('ready', '');
},
unmount() {
if (this.widgetId !== null) window.turnstile?.remove(this.widgetId);
this.widgetId = null;
},
// A theme switch cannot restyle a rendered widget, so render it again,
// unless the visitor already passed and holds a token.
followTheme() {
if (this.widgetId === null || this.state === 'verified') return;
if (this.resolveTheme() === this.renderedTheme) return;
this.unmount();
this.mount();
},
setToken(token) {
const input = this.$refs.input;
if (!input || input.value === token) return;
input.value = token;
input.dispatchEvent(new Event('input', { bubbles: true }));
},
clear(state, message = config.messages?.[state] ?? '') {
this.setToken('');
this.show(state, message);
this.$dispatch('turnstile-cleared', { reason: state });
},
show(state, message) {
this.state = state;
this.message = config.serverError || message;
},
// Fresh challenge after a failed submit spent the token. Keeps the
// server's error message until the visitor passes again.
refresh(serverError = '') {
config.serverError = serverError;
if (this.widgetId === null || !window.turnstile) {
if (serverError) this.show('error', '');
return;
}
this.setToken('');
window.turnstile.reset(this.widgetId);
this.show(serverError ? 'error' : 'ready', '');
},
reset() {
config.serverError = '';
this.setToken('');
this.show('loading', '');
if (this.widgetId !== null && window.turnstile) {
window.turnstile.reset(this.widgetId);
this.show('ready', '');
} else {
this.mount();
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0