Skip to content
Brok UI

Loading…

No results

Turnstile

Open source

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.

Version
v1.0.2
Stability
stable
License
MIT
Related
Form
Passkey
Input OTP

Preview

Size
previews.components.turnstile.default.blade.php 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="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>
Normal Current
Flexible Current
Compact Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/turnstile.blade.php
  • js resources/js/ui/turnstile.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

turnstile.md
# 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

compact.blade.php Blade
{{-- 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>
error.blade.php Blade
{{-- 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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

turnstile turnstile-message turnstile-widget

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

Freeform text input

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
root widget input message
Theming hooks
message tone Cloudflare widget theme

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/turnstile.blade.php Blade
{{--
    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
resources/js/ui/turnstile.js JS
/**
 * 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