A comprehensive Cloudflare Turnstile integration for Phoenix and LiveView applications with graceful failure handling that never blocks users.
- Graceful Failure Handling - Verification failures never block legitimate users
- Automatic CSP Configuration - Igniter-based installer handles Content Security Policy updates
- Phoenix LiveView Components - Drop-in components for easy widget rendering
- JavaScript Hook - Client-side widget management with automatic fallbacks
- Bypass Mode - Development-friendly bypass tokens for testing
- Zero-Config Development - Works out of the box without API keys
- Comprehensive Logging - Detailed console and server-side logging for debugging
Step 1: Add the dependency
# In mix.exs
def deps do
[
{:phoenix_turnstile, github: "zyzyva/phoenix_turnstile"}
]
endStep 2: Install dependencies
mix deps.getStep 3: Run the installer
mix igniter.install phoenix_turnstileThis automatically:
- ✅ Adds Turnstile configuration with test keys to
config/config.exs - ✅ Adds production environment variable configuration to
config/runtime.exs - ✅ Copies the JavaScript hook to
assets/js/turnstile_hook.js - ✅ Imports and registers the hook in your
assets/js/app.js
Step 4: Add the widget to your LiveView
def render(assigns) do
~H"""
<PhoenixTurnstile.Components.widget_with_loading id="turnstile-widget" />
"""
end
def handle_event("turnstile_callback", %{"token" => _token}, socket) do
{:noreply, socket}
endThat's it! The widget works immediately on localhost without any API keys.
For production, set environment variables:
export TURNSTILE_SITE_KEY="your_production_site_key"
export TURNSTILE_SECRET_KEY="your_production_secret_key"Get your keys from: https://dash.cloudflare.com/
The installer sets up a two-tier configuration strategy:
Development & Test (config/config.exs):
# Cloudflare test keys - work on localhost without configuration
config :phoenix_turnstile,
site_key: "1x00000000000000000000AA",
secret_key: "1x0000000000000000000000000000000AA"Production (config/runtime.exs):
if config_env() == :prod do
config :phoenix_turnstile,
site_key: System.get_env("TURNSTILE_SITE_KEY"),
secret_key: System.get_env("TURNSTILE_SECRET_KEY")
endThis configuration strategy ensures:
- ✅ Works immediately on localhost - test keys work on any domain without whitelisting
- ✅ Developers can have production keys set globally - they won't interfere with local development
- ✅ Test environment uses test keys - consistent test behavior, no API calls
- ✅ Production uses real keys - environment variables override test keys only in production
The test keys (1x00000000000000000000AA) are official Cloudflare test keys that:
- Work on any domain (localhost, staging, etc.)
- Don't require domain whitelisting in Cloudflare dashboard
- Are perfect for development and testing
- Always return successful verifications
For production, set your environment variables:
export TURNSTILE_SITE_KEY="your_production_site_key"
export TURNSTILE_SECRET_KEY="your_production_secret_key"These will automatically override the test keys only in production (when MIX_ENV=prod).
1. Add the component to your LiveView template:
<PhoenixTurnstile.Components.widget id="turnstile-widget" />2. Handle the callback in your LiveView:
defmodule MyAppWeb.ContactLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:turnstile_verified, false)
|> assign(:turnstile_token, nil)}
end
def handle_event("turnstile_callback", %{"token" => token}, socket) do
case PhoenixTurnstile.verify_token(token) do
{:ok, true} ->
{:noreply, assign(socket, :turnstile_verified, true, :turnstile_token, token)}
_ ->
# Verification failed but we still allow processing
{:noreply, assign(socket, :turnstile_verified, false)}
end
end
def handle_event("submit_form", params, socket) do
# Optionally check if verified
if socket.assigns.turnstile_verified do
# Process form
{:noreply, socket}
else
{:noreply, put_flash(socket, :error, "Please complete verification")}
end
end
end<PhoenixTurnstile.Components.widget_with_loading
id="turnstile"
loading_text="Verifying you're human..."
class="flex justify-center my-4"
/>Only show the widget when Turnstile is enabled:
<div :if={PhoenixTurnstile.enabled?()}>
<PhoenixTurnstile.Components.widget id="turnstile" />
</div>Reset the widget after form submission:
def handle_event("submit_form", _params, socket) do
# Process form...
{:noreply,
socket
|> push_event("reset_turnstile", %{})
|> assign(:turnstile_verified, false)}
endThe installer automatically updates your CSP headers to allow these Turnstile domains:
plug :put_secure_browser_headers, %{
"content-security-policy" =>
"default-src 'self'; " <>
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://*.cloudflare.com; " <>
"frame-src 'self' https://challenges.cloudflare.com https://*.cloudflare.com; " <>
"style-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; " <>
"connect-src 'self' https://challenges.cloudflare.com https://*.cloudflare.com; " <>
"child-src 'self' https://challenges.cloudflare.com https://*.cloudflare.com;"
}If the installer cannot automatically update your CSP, you'll receive a warning with manual instructions.
PhoenixTurnstile is designed to never block users, even when things go wrong:
- No API keys? → Bypasses verification (development mode)
- API unreachable? → Logs warning, allows processing
- Token verification fails? → Logs warning, allows processing
- JavaScript errors? → Sends bypass token
- Widget won't load? → Sends bypass token
This ensures legitimate users are never frustrated by CAPTCHA issues while still providing bot protection when everything works correctly.
-
Backend Verification (
PhoenixTurnstile.Verification)- Token verification via Cloudflare API
- Graceful failure handling
- Bypass token support
-
Phoenix Components (
PhoenixTurnstile.Components)widget/1- Basic Turnstile widgetwidget_with_loading/1- Widget with loading indicator
-
JavaScript Hook (
TurnstileHook)- Automatic script loading
- Widget lifecycle management
- Bypass token generation on errors
- Console logging for debugging
# Run all tests
mix test
# Run with coverage
mix test --coverWhen testing forms with Turnstile in your application:
Option 1: Use bypass tokens (recommended)
test "processes form with bypass token", %{conn: conn} do
# Bypass tokens are always accepted
socket
|> form("#contact-form", %{token: "bypass-test"})
|> render_submit()
endOption 2: Mock the verification
import Mox
# Define a mock in test/support/mocks.ex
Mox.defmock(PhoenixTurnstile.VerificationMock, for: PhoenixTurnstile.VerificationBehaviour)
# In your test
PhoenixTurnstile.VerificationMock
|> expect(:verify_token, fn _token -> {:ok, true} end)Main module with convenience functions.
PhoenixTurnstile.enabled?() :: boolean()
PhoenixTurnstile.site_key() :: String.t() | nil
PhoenixTurnstile.verify_token(token) :: {:ok, boolean()} | {:error, String.t()}Backend token verification.
verify_token(token) :: {:ok, boolean()} | {:error, String.t()}
enabled?() :: boolean()
site_key() :: String.t() | nilPhoenix LiveView components.
widget(assigns) :: Phoenix.LiveView.Rendered.t()
widget_with_loading(assigns) :: Phoenix.LiveView.Rendered.t()Problem: Widget doesn't render or console shows window.turnstile.render is not a function
Cause: You're using id="turnstile" which creates a global window.turnstile variable that overwrites the Cloudflare Turnstile API.
Solution: Use a different ID:
# ❌ BAD - causes naming collision
<PhoenixTurnstile.Components.widget id="turnstile" />
# ✅ GOOD - no collision
<PhoenixTurnstile.Components.widget id="turnstile-widget" />
<PhoenixTurnstile.Components.widget id="my-turnstile" />- Check browser console for JavaScript errors
- Verify CSP headers allow Turnstile domains
- Ensure
data-sitekeyattribute is set correctly - Make sure you're not using
id="turnstile"(see above)
- Check that
TURNSTILE_SECRET_KEYis set - Verify the secret key matches your site key (sandbox vs production)
- Check server logs for API errors
- Ensure your server can reach
challenges.cloudflare.com
If you see CSP errors in the browser console:
- Check that the installer updated your router correctly
- Manually add Turnstile domains to your CSP if needed
- Look in your router for
plug :put_secure_browser_headers
Turnstile automatically uses bypass mode in development when keys aren't configured. If you want to test with real keys:
export USE_PROD_TURNSTILE=true
export TURNSTILE_SITE_KEY="your_sandbox_key"
export TURNSTILE_SECRET_KEY="your_sandbox_secret"- Dashboard: https://dash.cloudflare.com/
- Turnstile Setup: https://dash.cloudflare.com/?to=/:account/turnstile
- Documentation: https://developers.cloudflare.com/turnstile/
Turnstile is ideal for:
- Public forms (contact, registration, etc.)
- API endpoints that need rate limiting
- Comment sections and user-generated content
- Password reset flows
Don't rely solely on Turnstile for:
- Financial transactions (use additional fraud detection)
- Administrative actions (use proper authentication/authorization)
- Critical security decisions (use defense-in-depth)
- Always verify server-side - Never trust client-side verification alone
- Use HTTPS - Turnstile requires HTTPS in production
- Monitor logs - Watch for unusual bypass rates
- Rate limiting - Combine with rate limiting for API endpoints
- Graceful degradation - Follow this library's approach of never blocking users
- Fork the repository
- Create a feature branch
- Make your changes with tests
- Run
mix formatandmix test - Submit a pull request
MIT
Built by the Zyzyva Team, inspired by the Turnstile integration patterns in the contacts4us project.