Persiltech.Turnstile.Blazor
1.0.5
dotnet add package Persiltech.Turnstile.Blazor --version 1.0.5
NuGet\Install-Package Persiltech.Turnstile.Blazor -Version 1.0.5
<PackageReference Include="Persiltech.Turnstile.Blazor" Version="1.0.5" />
<PackageVersion Include="Persiltech.Turnstile.Blazor" Version="1.0.5" />
<PackageReference Include="Persiltech.Turnstile.Blazor" />
paket add Persiltech.Turnstile.Blazor --version 1.0.5
#r "nuget: Persiltech.Turnstile.Blazor, 1.0.5"
#:package Persiltech.Turnstile.Blazor@1.0.5
#addin nuget:?package=Persiltech.Turnstile.Blazor&version=1.0.5
#tool nuget:?package=Persiltech.Turnstile.Blazor&version=1.0.5
Persiltech.Turnstile.Blazor
A modern Blazor WebAssembly component library for Cloudflare Turnstile integration with clean architecture and built-in verification support using the Result pattern.
β¨ Features
- Cloudflare Turnstile Integration - User-friendly CAPTCHA alternative
- Built-in Verification - Component handles both token generation and validation
- Result Pattern - Returns
AppResult(Persiltech.Result) for elegant error handling - Customizable UI - Theme, size, and appearance options
- Clean Architecture - Well-structured, maintainable, testable code
- Type-Safe - Full C# nullable reference types support
- Detailed Error Codes - Specific error codes for different failure scenarios
- Functional API - Support for both imperative and functional programming styles
- Multiple Instances - Support for multiple components on the same page
π¦ Installation
Client (Blazor WebAssembly)
dotnet add package Persiltech.Turnstile.Blazor
Server (ASP.NET Core Web API)
dotnet add package Persiltech.Captcha.Factory
Note:
Persiltech.Captcha.Factoryprovides the verification endpoint automatically.
βοΈ Configuration
Client Setup
1. Register services in Program.cs:
using Persiltech.Turnstile.Blazor;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddTurnstileServices(builder.Configuration);
// Optionally configure the HttpClient used to call your verification endpoint:
// builder.Services.AddTurnstileServices(
// builder.Configuration,
// httpClientBuilder => httpClientBuilder.AddLocalizationDelegatingHandler());
await builder.Build().RunAsync();
2. Configure appsettings.json:
β οΈ The section is named
Turnstile(the value ofTurnstileOptions.SectionKey), notTurnstileOptions. A missing section throwsInvalidOperationException: Configuration section 'Turnstile' not foundon startup.
{
"Turnstile": {
"SiteKey": "your-turnstile-site-key",
"WebApiBaseAddress": "https://your-api-domain.com"
}
}
Both keys are required β TurnstileOptionsValidator fails startup validation if either is empty.
3. Load Cloudflare's script in index.html:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
Without it the component logs api.js no estΓ‘ cargado and renders nothing.
Server Setup
1. Register services and endpoint in Program.cs:
using Persiltech.Captcha.Factory;
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddCaptcha(builder.Configuration);
var app = builder.Build();
// Register endpoint (POST /captcha/verify)
app.MapCaptchaVerifyEndpoint();
app.Run();
2. Configure appsettings.json:
{
"CaptchaOptions": {
"DefaultProvider": "turnstile",
"Turnstile": {
"SecretKey": "your-turnstile-secret-key",
"VerifyEndpoint": "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
}
}
π Usage
Basic Example (Imperative Style)
@page "/contact"
@using Persiltech.Turnstile.Blazor.Components
@using Persiltech.Turnstile.Blazor.Enums
@using Persiltech.Result
<EditForm Model="Model" OnValidSubmit="HandleSubmitAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label>Name</label>
<InputText @bind-Value="Model.Name" class="form-control" />
<ValidationMessage For="@(() => Model.Name)" />
</div>
<div class="mb-3">
<label>Email</label>
<InputText @bind-Value="Model.Email" class="form-control" />
<ValidationMessage For="@(() => Model.Email)" />
</div>
<TurnstileComponent @ref="_turnstile"
Action="contact_form"
Theme="TurnstileTheme.Light"
Size="TurnstileSize.Normal"
OnSuccess="HandleSuccess"
OnError="HandleError"
OnExpired="HandleExpired" />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger">@_errorMessage</div>
}
<button type="submit" class="btn btn-primary" disabled="@_isSubmitting">
@(_isSubmitting ? "Submitting..." : "Submit")
</button>
</EditForm>
@code {
private TurnstileComponent _turnstile = default!;
private bool _isSubmitting;
private string? _errorMessage;
private string? _currentToken;
private ContactModel Model { get; set; } = new();
private async Task HandleSubmitAsync()
{
_isSubmitting = true;
_errorMessage = null;
try
{
// Token is automatically obtained when user completes challenge
if (string.IsNullOrEmpty(_currentToken))
{
_errorMessage = "Please complete the security check";
return;
}
// Verify token with Result pattern
var result = await _turnstile.VerifyAsync(_currentToken);
// Check for errors
if (result.IsFailure)
{
_errorMessage = GetFriendlyErrorMessage(result.ErrorMessage);
await _turnstile.ResetAsync(); // Reset widget for retry
return;
}
// Process form
await SubmitFormAsync();
}
finally
{
_isSubmitting = false;
}
}
private void HandleSuccess(string token)
{
_currentToken = token;
_errorMessage = null;
StateHasChanged();
}
private void HandleError()
{
_errorMessage = "Security check failed. Please try again.";
_currentToken = null;
StateHasChanged();
}
private async Task HandleExpired()
{
_errorMessage = "Security check expired. Please try again.";
_currentToken = null;
await _turnstile.ResetAsync();
StateHasChanged();
}
private string GetFriendlyErrorMessage(string? errorCode)
{
return errorCode switch
{
"missing-token" => "Security check not completed.",
"invalid-token" => "Security check is invalid. Please try again.",
"network-error" => "Network error. Please check your connection.",
"timeout-error" => "Verification timed out. Please try again.",
_ => "Security verification failed. Please try again."
};
}
private async Task SubmitFormAsync()
{
// Your form submission logic
await Task.Delay(1000);
_errorMessage = "Form submitted successfully!";
}
public class ContactModel
{
[Required]
public string Name { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
}
}
Functional Style with Match
@using Persiltech.Result.Extensions
@code {
private async Task HandleSubmitAsync()
{
if (string.IsNullOrEmpty(_currentToken))
{
_errorMessage = "Please complete the security check";
return;
}
_isSubmitting = true;
try
{
var result = await _turnstile.VerifyAsync(_currentToken);
// Match takes an Action for success and an Action<AppResult> for failure
Func<Task> next = () => Task.CompletedTask;
result.Match(
onSuccess: () => next = SubmitFormAsync,
onError: failed => next = async () =>
{
_errorMessage = GetFriendlyErrorMessage(failed.ErrorMessage);
await _turnstile.ResetAsync();
});
await next();
}
finally
{
_isSubmitting = false;
}
}
}
Login Example with Auto Theme
@page "/login"
@using Persiltech.Turnstile.Blazor.Components
@using Persiltech.Turnstile.Blazor.Enums
<EditForm Model="Model" OnValidSubmit="HandleLoginAsync">
<InputText @bind-Value="Model.Email" placeholder="Email" class="form-control mb-3" />
<InputText @bind-Value="Model.Password" type="password" placeholder="Password" class="form-control mb-3" />
<TurnstileComponent @ref="_turnstile"
Action="login"
Theme="TurnstileTheme.Auto"
Size="TurnstileSize.Normal"
OnSuccess="token => _token = token"
OnError="HandleError"
OnExpired="HandleExpired" />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger">@_errorMessage</div>
}
<button type="submit" class="btn btn-primary w-100">Login</button>
</EditForm>
@code {
private TurnstileComponent _turnstile = default!;
private string? _token;
private string? _errorMessage;
private LoginModel Model { get; set; } = new();
private async Task HandleLoginAsync()
{
if (string.IsNullOrEmpty(_token))
{
_errorMessage = "Please complete the security check";
return;
}
var result = await _turnstile.VerifyAsync(_token);
if (result.IsFailure)
{
_errorMessage = "Security verification failed";
await _turnstile.ResetAsync();
return;
}
await AuthService.LoginAsync(Model);
}
private void HandleError() => _errorMessage = "Security check failed";
private async Task HandleExpired()
{
_errorMessage = "Security check expired";
await _turnstile.ResetAsync();
}
}
Compact Widget Example
<TurnstileComponent Action="newsletter_signup"
Size="TurnstileSize.Compact"
Theme="TurnstileTheme.Light"
OnSuccess="token => _newsletterToken = token"
OnError="() => ShowError('Verification failed')"
OnExpired="() => ShowError('Verification expired')" />
π API Reference
TurnstileComponent
Parameters
| Parameter | Type | Default | Required | Description |
|---|---|---|---|---|
Action |
string |
- | β Yes | Action identifier for analytics (e.g., "login", "register", "contact") |
Theme |
TurnstileTheme? |
Auto |
No | Widget theme: Light, Dark, or Auto |
Size |
TurnstileSize |
Normal |
No | Widget size: Normal, Compact |
Appearance |
TurnstileAppearance |
Always |
No | Widget visibility: Always, Execute, InteractionOnly |
Language |
string? |
Browser default | No | Language code (e.g., "es", "en", "fr") |
OnSuccess |
EventCallback<string> |
- | β Yes | Callback when user successfully completes challenge (receives token) |
OnError |
EventCallback |
- | β Yes | Callback when challenge encounters an error |
OnExpired |
EventCallback |
- | β Yes | Callback when token expires |
Methods
VerifyAsync(string? token)
Verifies the token with your backend using the Result pattern.
Task<AppResult> VerifyAsync(string? token)
Parameters:
token- Token received fromOnSuccesscallback. A null or blank token fails fast withmissing-tokenβ it never reaches the network.
Returns: AppResult (from Persiltech.Result) containing:
IsFailure-trueif verification failedIsSuccess-trueif verification succeededErrorMessage- The error code when verification failed (see the table below)Errors- All errors recorded on the result
Example:
var result = await _turnstile.VerifyAsync(token);
if (result.IsFailure)
{
Console.WriteLine($"Error: {result.ErrorMessage}");
}
ResetAsync()
Resets the widget to allow user to retry. If Cloudflare no longer recognises the widget β which happens when Blazor re-creates the container node β the widget is re-rendered instead of being left unusable.
ValueTask ResetAsync()
Example:
if (result.IsFailure)
{
await _turnstile.ResetAsync(); // Reset for retry
}
RemoveAsync()
Removes the widget from the DOM.
ValueTask RemoveAsync()
Example:
// Clean up when component is no longer needed
await _turnstile.RemoveAsync();
π¨ Enums
TurnstileTheme
Widget color theme.
public enum TurnstileTheme
{
Light, // Light theme
Dark, // Dark theme
Auto // Follows system preference (default)
}
TurnstileSize
Widget size.
public enum TurnstileSize
{
Normal, // Standard size: 300x65px (default)
Flexible, // Fills the width of its container (min. 300px)
Compact // Compact size: 130x120px
}
TurnstileAppearance
Widget visibility behavior.
public enum TurnstileAppearance
{
Always, // Always visible (default)
Execute, // Hidden until explicitly executed
InteractionOnly // Only shown when interaction is required
}
π Error Codes
The VerifyAsync() method reports the error code in AppResult.ErrorMessage:
| Error Code | Description | Recommended Action |
|---|---|---|
missing-token |
Token is null or blank; no request is sent | Ensure user completed challenge |
network-error |
The verification endpoint could not be reached | Retry automatically or ask user to check connection |
timeout-error |
Verification request timed out | Retry with exponential backoff |
unauthorized |
The endpoint answered 401/403 | Check authentication on your verification endpoint |
http-error-XXX |
HTTP error from your endpoint (e.g., http-error-400 for an invalid token, http-error-502 when Cloudflare is unreachable) |
Reset widget and retry, or show service unavailable |
unknown-error |
Unexpected client-side failure | Reset widget and retry |
The provider-level codes Cloudflare returns (
invalid-input-response,timeout-or-duplicate, β¦) are logged server-side byPersiltech.Turnstile.Serverand surface here ashttp-error-400.
Example of handling specific errors:
var result = await _turnstile.VerifyAsync(token);
if (result.IsFailure)
{
var (message, shouldReset) = result.ErrorMessage switch
{
"http-error-400" =>
("Verification expired. Please try again.", true),
"network-error" or "timeout-error" =>
("Connection error. Retrying...", false),
_ =>
("Verification failed. Please try again.", true)
};
ShowError(message);
if (shouldReset)
await _turnstile.ResetAsync();
}
π§ How It Works
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Blazor WebAssembly (Client) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 1. User completes Turnstile challenge β
β 2. OnSuccess(token) β Store token β
β 3. VerifyAsync(token) β POST /captcha/verify β
β 4. Receive AppResult carrying an error code β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ASP.NET Core API (Server) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 5. Validate token with Cloudflare β
β 6. Return CaptchaVerificationResult or ProblemDetails β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π Troubleshooting
Token Not Being Generated
Cause: User hasn't completed the challenge.
Solution:
- Ensure
OnSuccesscallback is properly wired - Check browser console for JavaScript errors
- Verify Site Key in configuration
Verification Returns http-error-400
Common Causes:
- Token already used (tokens are single-use)
- Token expired (valid for ~5 minutes)
- Mismatched keys (Site Key vs Secret Key)
Solution:
if (result.ErrorMessage == "http-error-400")
{
await _turnstile.ResetAsync(); // Allow user to retry
}
[Cloudflare Turnstile] Nothing to reset found for provided container
Cause: ResetAsync() ran against a widget whose container Blazor had already
re-created, so Cloudflare no longer knows the widget id.
Solution: Fixed in 1.0.5 β the JS layer now contains the error and the component
re-renders the widget instead of leaving it unusable. If you still see it, make sure
you are not calling RemoveAsync() and ResetAsync() on the same instance.
Widget Not Visible
Causes:
Appearance = InteractionOnly(intended behavior)- CSS conflicts
- Ad blocker interference
Solution:
- Set
Appearance = Alwaysfor testing - Check browser developer tools for CSS issues
- Temporarily disable ad blockers
network-error or timeout-error
Solution: Implement retry logic:
private async Task<AppResult> VerifyWithRetryAsync(
string token,
int maxRetries = 3)
{
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
var result = await _turnstile.VerifyAsync(token);
if (result.IsSuccess)
return result;
if (result.ErrorMessage is not ("network-error" or "timeout-error") || attempt == maxRetries)
return result;
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)));
}
return AppResult.Fail("max-retries-exceeded");
}
CORS Issues
Configure CORS in your backend:
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowClient", policy =>
policy.WithOrigins("https://your-domain.com")
.AllowAnyMethod()
.AllowAnyHeader());
});
app.UseCors("AllowClient");
π― Best Practices
1. Always Handle All Three Callbacks
<TurnstileComponent OnSuccess="HandleSuccess"
OnError="HandleError"
OnExpired="HandleExpired" />
2. Reset on Errors
private async Task HandleError()
{
_errorMessage = "Verification failed";
await _turnstile.ResetAsync(); // Allow retry
}
3. Store Token Immediately
private void HandleSuccess(string token)
{
_currentToken = token;
_errorMessage = null;
// Optionally auto-submit form
// await HandleSubmitAsync();
}
4. Use Appropriate Size
<TurnstileComponent Size="TurnstileSize.Normal" />
<TurnstileComponent Size="TurnstileSize.Compact" />
5. Match Theme to Your Design
<TurnstileComponent Theme="TurnstileTheme.Light" />
<TurnstileComponent Theme="TurnstileTheme.Dark" />
<TurnstileComponent Theme="TurnstileTheme.Auto" />
6. Use Specific Action Names
<TurnstileComponent Action="login" />
<TurnstileComponent Action="register" />
<TurnstileComponent Action="checkout" />
π¦ Related Packages
Required
- Persiltech.Captcha.Factory - Server-side multi-provider verification orchestration
- Persiltech.Turnstile.Server - Cloudflare Turnstile server implementation
- Persiltech.Result - Result pattern library (included as dependency)
Optional
- Persiltech.Captcha.Contracts - Shared contracts for custom implementations
Alternative
- Persiltech.Recaptcha.Blazor - Google reCAPTCHA v3 (invisible CAPTCHA)
π Resources
- Cloudflare Turnstile Documentation
- Get Turnstile Keys
- Blazor Documentation
- Persiltech.Result Documentation
βοΈ Turnstile vs reCAPTCHA
| Feature | Turnstile | reCAPTCHA v3 |
|---|---|---|
| Visibility | Visible widget | Invisible |
| User Interaction | May require interaction | No interaction |
| Privacy | Privacy-focused, no tracking | Tracks user behavior |
| Speed | Fast challenges | Background analysis |
| Customization | Theme, size options | Limited customization |
| Best For | Privacy-conscious apps | Invisible security |
π License
Copyright Β© 2026 Persiltech. All rights reserved.
Licensed under the MIT License.
π§ Support
For issues or questions, please open an issue on the GitHub repository.
Made with β€οΈ by Edinson Aldaz
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.9)
- Microsoft.Extensions.Http (>= 10.0.9)
- Persiltech.Blazor.JSInterop (>= 1.0.1)
- Persiltech.HttpDelegatingHandlers (>= 1.0.2)
- Persiltech.Result (>= 1.0.6)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Persiltech.Turnstile.Blazor:
| Package | Downloads |
|---|---|
|
Persiltech.Membership.Blazor
Contains razor clases for use in frontend membership projects |
GitHub repositories
This package is not used by any popular GitHub repositories.