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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Persiltech.Turnstile.Blazor" Version="1.0.5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Persiltech.Turnstile.Blazor" Version="1.0.5" />
                    
Directory.Packages.props
<PackageReference Include="Persiltech.Turnstile.Blazor" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Persiltech.Turnstile.Blazor --version 1.0.5
                    
#r "nuget: Persiltech.Turnstile.Blazor, 1.0.5"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Persiltech.Turnstile.Blazor@1.0.5
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Persiltech.Turnstile.Blazor&version=1.0.5
                    
Install as a Cake Addin
#tool nuget:?package=Persiltech.Turnstile.Blazor&version=1.0.5
                    
Install as a Cake Tool

Persiltech.Turnstile.Blazor

NuGet License

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.Factory provides 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 of TurnstileOptions.SectionKey), not TurnstileOptions. A missing section throws InvalidOperationException: Configuration section 'Turnstile' not found on 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 from OnSuccess callback. A null or blank token fails fast with missing-token β€” it never reaches the network.

Returns: AppResult (from Persiltech.Result) containing:

  • IsFailure - true if verification failed
  • IsSuccess - true if verification succeeded
  • ErrorMessage - 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 by Persiltech.Turnstile.Server and surface here as http-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 OnSuccess callback 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:

  1. Set Appearance = Always for testing
  2. Check browser developer tools for CSS issues
  3. 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" />

Required

Optional

Alternative


πŸ”— Resources


βš–οΈ 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.5 119 8/13/2026
1.0.4 138 7/5/2026
1.0.3 115 6/28/2026
1.0.2 120 6/6/2026
1.0.1 108 5/5/2026
1.0.0 113 5/3/2026