A reusable, SSR-safe Cloudflare Turnstile CAPTCHA component for Vue 3 — made to pair seamlessly with Laravel via njoguamos/laravel-turnstile.
- ✅ SSR-safe with hydration checks
- 🔁 Auto-reset on error/expired (optional)
- 🔒
v-model
for reactive token binding - 🧩 Exposes
reset()
andexecute()
methods - 🧠 Designed to work with Laravel (Inertia, Blade, Livewire)
- ⚙️ Server-side validation handled via
njoguamos/laravel-turnstile
npm install @delaneydev/laravel-turnstile-vue
This component is designed to work alongside:
composer require njoguamos/laravel-turnstile
php artisan turnstile:install
TURNSTILE_SITE_KEY=your-site-key
TURNSTILE_SECRET_KEY=your-secret-key
TURNSTILE_ENABLED=true
# Use TURNSTILE_ENABLED=false to disable in testing/dev
Update your HandleInertiaRequests.php
middleware:
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'turnstile_site_key' => env('TURNSTILE_SITE_KEY'),
]);
}
To apply Cloudflare Turnstile validation to Jetstream login and password routes, override the default auth routes importing the Jetstream controllers directly from vendor (do not create new ones) in routes/web.php
.
// Override Jetstream login routes to include 'turnstile' middleware
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\NewPasswordController;
Route::post('/login', [AuthenticatedSessionController::class, 'store'])
->middleware(['guest', 'turnstile'])
->name('login');
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
->middleware(['guest', 'turnstile'])
->name('password.email');
Route::post('/reset-password', [NewPasswordController::class, 'store'])
->middleware(['guest', 'turnstile'])
->name('password.update');
For a full explanation of why this override is needed, this way we dont break jetstream or need to maintain our own auth controllers direct from vendor read this article:
Integrating Cloudflare Turnstile CAPTCHA with Laravel Jetstream by Delaney Wright
use NjoguAmos\Turnstile\Rules\TurnstileRule;
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'token' => ['required', new TurnstileRule()],
];
}
}
use NjoguAmos\Turnstile\Rules\TurnstileRule;
public function store(Request $request)
{
$validated = $request->validate([
'token' => ['required', new TurnstileRule()],
]);
}
<script setup lang="ts">
import { TurnstileWidget } from '@delaneydev/laravel-turnstile-vue'
const captchaToken = ref('')
</script>
<template>
<TurnstileWidget
v-model="captchaToken"
:sitekey="$page.props.turnstile_site_key"
theme="light"
/>
</template>
<script setup lang="ts">
import { useForm, usePage } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
const captchaToken = ref('')
const form = useForm({
email: '',
password: '',
remember: false,
})
const submit = () => {
form
.transform(data => ({
...data,
'cf-turnstile-response': captchaToken.value,
remember: form.remember ? 'on' : '',
}))
.post(route('login'), {
onFinish: () => {
form.reset('password')
captchaToken.value = ''
},
onError: () => {
captchaToken.value = ''
},
})
}
</script>
<template>
<form @submit.prevent="submit">
<!-- Email, password, remember fields -->
<TurnstileWidget
v-model="captchaToken"
:sitekey="$page.props.turnstile_site_key"
theme="dark"
/>
<button type="submit">Log in</button>
</form>
</template>
Prop | Type | Default | Description |
---|---|---|---|
sitekey |
string |
— | Your Cloudflare Turnstile site key (required) |
modelValue |
string |
— | Bound CAPTCHA token via v-model
|
theme |
string |
'light' |
light or dark
|
size |
string |
'normal' |
normal , compact , or invisible
|
disableAutoReload |
boolean |
false |
Prevents auto-reset on error/expired |
Event | Payload | Description |
---|---|---|
update:modelValue |
string |
Token emitted after success |
error |
— | Widget failed to load |
expired |
— | Widget expired (auto-reset if enabled) |
<script setup>
const captcha = ref()
</script>
<template>
<TurnstileWidget ref="captcha" sitekey="..." v-model="token" />
<button @click="captcha?.execute()">Force Execute</button>
</template>
✅ Out-of-the-box SSR safe.
- Uses
v-if="hydrated"
to defer rendering until client - Checks
typeof window !== 'undefined'
to prevent SSR DOM issues - Compatible with:
- Nuxt 3
- Laravel SSR (Inertia)
- Vite SSR
- Vue CLI/Nitro setups
You can use this in any Vue 3 project:
<TurnstileWidget
sitekey="your-site-key"
v-model="captchaToken"
/>
Just handle the token validation via your own backend logic or API if you're not using Laravel.
MIT © DelaneyDev